@cocorograph/hub-agent 0.6.3 → 0.6.4
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 +1 -1
- package/src/claude-stream-bridge.mjs +141 -160
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
4
|
+
* **0.6.4 で「1 メッセージ = 1 query(resume チェーン)」モデルに変更。**
|
|
5
|
+
*
|
|
6
|
+
* 旧設計 (streaming-input iterator + 常駐 query) は SDK の resume と相性が悪く、
|
|
7
|
+
* resume 時に「Continue from where you left off」の自動継続ターンが走り 1 ターンで
|
|
8
|
+
* query() が終了 → 後続入力が消費されない不具合があった (ユーザー報告 2026-05-28)。
|
|
9
|
+
*
|
|
10
|
+
* 新設計:
|
|
11
|
+
* - attach: query() を起動せず、現在の resumeSessionId を記録するだけ (即 ready)
|
|
12
|
+
* - input: ユーザーメッセージ 1 件ごとに query({ prompt: text, options: { resume } })
|
|
13
|
+
* を起動。1 ターン分のイベントを stream して result で完了。完了時に最新 session_id を
|
|
14
|
+
* 保持し、次の input はその session_id で resume チェーンする
|
|
15
|
+
* - これは Claude CLI の `claude --resume <id> -p "<msg>"` を毎ターン叩くのと同じ
|
|
16
|
+
* モデルで、SDK の挙動と素直に噛み合う
|
|
13
17
|
*
|
|
14
18
|
* PtyBridge と並走させる設計。`pty.*` 系メッセージは無傷で、新規 `claude.*` のみ
|
|
15
19
|
* 受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
@@ -17,28 +21,25 @@
|
|
|
17
21
|
import { EventEmitter } from "node:events"
|
|
18
22
|
import { randomUUID } from "node:crypto"
|
|
19
23
|
|
|
24
|
+
/** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
|
|
25
|
+
function extractPromptText(message) {
|
|
26
|
+
if (typeof message === "string") return message
|
|
27
|
+
if (!message) return ""
|
|
28
|
+
const content = message.content
|
|
29
|
+
if (typeof content === "string") return content
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
return content
|
|
32
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
33
|
+
.map((b) => b.text)
|
|
34
|
+
.join("\n")
|
|
35
|
+
}
|
|
36
|
+
return ""
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
|
-
* 1 stream に対応する Claude
|
|
22
|
-
*
|
|
23
|
-
* SDK の `query()` は prompt を AsyncIterable として受け取り、ユーザーメッセージを
|
|
24
|
-
* 流し続ける限り interactive multi-turn として動作する。pushInput / endInput で
|
|
25
|
-
* iterator の挙動を制御する。
|
|
40
|
+
* 1 stream に対応する Claude セッション (per-message query モデル)。
|
|
26
41
|
*/
|
|
27
42
|
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
43
|
constructor({
|
|
43
44
|
stream_id,
|
|
44
45
|
cwd,
|
|
@@ -56,7 +57,6 @@ class ClaudeStreamSession {
|
|
|
56
57
|
this.cwd = cwd
|
|
57
58
|
this.model = model || null
|
|
58
59
|
this.permissionMode = permissionMode || null
|
|
59
|
-
this.resumeSessionId = resumeSessionId || null
|
|
60
60
|
this.sdk = sdk
|
|
61
61
|
this.logger = logger
|
|
62
62
|
this.onEvent = onEvent
|
|
@@ -64,93 +64,21 @@ class ClaudeStreamSession {
|
|
|
64
64
|
this.onExit = onExit
|
|
65
65
|
this.onError = onError
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
|
|
67
|
+
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
69
68
|
this.sessionId = resumeSessionId || null
|
|
70
69
|
|
|
71
|
-
/** @type {
|
|
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 応答待ち */
|
|
70
|
+
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
76
71
|
this._permissionResolvers = new Map()
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
this.
|
|
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
|
-
}
|
|
72
|
+
/** 現在ターン実行中か (多重 query 防止) */
|
|
73
|
+
this._busy = false
|
|
74
|
+
/** detach 済みフラグ (新規ターン受付停止) */
|
|
75
|
+
this._closed = false
|
|
76
|
+
/** 現在ターンの AbortController (interrupt 用) */
|
|
77
|
+
this._abortController = null
|
|
150
78
|
}
|
|
151
79
|
|
|
152
80
|
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
153
|
-
async _canUseTool(toolName, input
|
|
81
|
+
async _canUseTool(toolName, input) {
|
|
154
82
|
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
155
83
|
const request_id = randomUUID()
|
|
156
84
|
return await new Promise((resolve) => {
|
|
@@ -168,29 +96,58 @@ class ClaudeStreamSession {
|
|
|
168
96
|
})
|
|
169
97
|
}
|
|
170
98
|
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
99
|
+
/** browser からの permission 応答を該当 request_id の Promise に渡す。 */
|
|
100
|
+
resolvePermission(request_id, decision) {
|
|
101
|
+
const r = this._permissionResolvers.get(request_id)
|
|
102
|
+
if (!r) return false
|
|
103
|
+
this._permissionResolvers.delete(request_id)
|
|
104
|
+
r.resolve(decision)
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
110
|
+
* 既存ターン実行中は無視 (UI 側で送信を抑止する想定だが念のため)。
|
|
111
|
+
*/
|
|
112
|
+
async sendMessage(message) {
|
|
113
|
+
if (this._closed) return
|
|
114
|
+
if (this._busy) {
|
|
115
|
+
this.logger?.warn(
|
|
116
|
+
{ stream_id: this.stream_id },
|
|
117
|
+
"claude session busy, message ignored",
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const prompt = extractPromptText(message)
|
|
122
|
+
if (!prompt) return
|
|
123
|
+
|
|
124
|
+
this._busy = true
|
|
125
|
+
this._abortController = new AbortController()
|
|
126
|
+
let aborted = false
|
|
185
127
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
128
|
+
const options = {
|
|
129
|
+
cwd: this.cwd,
|
|
130
|
+
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
131
|
+
includePartialMessages: true,
|
|
132
|
+
abortController: this._abortController,
|
|
133
|
+
}
|
|
134
|
+
if (this.model) options.model = this.model
|
|
135
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
136
|
+
// 直前ターンまでの session_id があれば resume チェーン
|
|
137
|
+
if (this.sessionId) options.resume = this.sessionId
|
|
190
138
|
|
|
139
|
+
try {
|
|
140
|
+
const generator = this.sdk.query({ prompt, options })
|
|
191
141
|
for await (const msg of generator) {
|
|
192
|
-
|
|
193
|
-
|
|
142
|
+
if (
|
|
143
|
+
msg?.type === "system" &&
|
|
144
|
+
msg?.subtype === "init" &&
|
|
145
|
+
typeof msg.session_id === "string"
|
|
146
|
+
) {
|
|
147
|
+
this.sessionId = msg.session_id
|
|
148
|
+
}
|
|
149
|
+
// result イベントでも session_id が来ることがある (念のため拾う)
|
|
150
|
+
if (msg?.type === "result" && typeof msg.session_id === "string") {
|
|
194
151
|
this.sessionId = msg.session_id
|
|
195
152
|
}
|
|
196
153
|
try {
|
|
@@ -203,12 +160,9 @@ class ClaudeStreamSession {
|
|
|
203
160
|
}
|
|
204
161
|
}
|
|
205
162
|
} catch (err) {
|
|
206
|
-
if (this.
|
|
207
|
-
|
|
208
|
-
reason = "aborted"
|
|
163
|
+
if (this._abortController?.signal?.aborted) {
|
|
164
|
+
aborted = true
|
|
209
165
|
} else {
|
|
210
|
-
code = 1
|
|
211
|
-
reason = err?.message || String(err)
|
|
212
166
|
try {
|
|
213
167
|
this.onError?.(err)
|
|
214
168
|
} catch {
|
|
@@ -216,17 +170,47 @@ class ClaudeStreamSession {
|
|
|
216
170
|
}
|
|
217
171
|
}
|
|
218
172
|
} finally {
|
|
219
|
-
this.
|
|
173
|
+
this._busy = false
|
|
174
|
+
this._abortController = null
|
|
175
|
+
// 未解決 permission は閉じる
|
|
176
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
177
|
+
try {
|
|
178
|
+
resolver.resolve({ behavior: "deny", message: "turn ended" })
|
|
179
|
+
} catch {
|
|
180
|
+
/* ignore */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this._permissionResolvers.clear()
|
|
184
|
+
if (aborted) {
|
|
185
|
+
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
|
|
191
|
+
abortTurn() {
|
|
192
|
+
if (this._abortController) {
|
|
220
193
|
try {
|
|
221
|
-
this.
|
|
222
|
-
} catch
|
|
223
|
-
|
|
224
|
-
{ err: err.message, stream_id: this.stream_id },
|
|
225
|
-
"onExit callback threw",
|
|
226
|
-
)
|
|
194
|
+
this._abortController.abort()
|
|
195
|
+
} catch {
|
|
196
|
+
/* ignore */
|
|
227
197
|
}
|
|
228
198
|
}
|
|
229
199
|
}
|
|
200
|
+
|
|
201
|
+
/** セッション終了。新規ターンを止め、実行中なら中断する。 */
|
|
202
|
+
close() {
|
|
203
|
+
this._closed = true
|
|
204
|
+
this.abortTurn()
|
|
205
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
206
|
+
try {
|
|
207
|
+
resolver.resolve({ behavior: "deny", message: "closed" })
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this._permissionResolvers.clear()
|
|
213
|
+
}
|
|
230
214
|
}
|
|
231
215
|
|
|
232
216
|
export class ClaudeStreamBridge extends EventEmitter {
|
|
@@ -278,10 +262,6 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
278
262
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
279
263
|
this.emit("permission", { stream_id, request_id, tool_name, input })
|
|
280
264
|
},
|
|
281
|
-
onExit: ({ code, reason, session_id }) => {
|
|
282
|
-
this.sessions.delete(stream_id)
|
|
283
|
-
this.emit("exit", { stream_id, code, reason, session_id })
|
|
284
|
-
},
|
|
285
265
|
onError: (err) => {
|
|
286
266
|
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
287
267
|
},
|
|
@@ -291,24 +271,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
291
271
|
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
292
272
|
"claude stream attached",
|
|
293
273
|
)
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
this.logger?.error(
|
|
297
|
-
{ stream_id, err: err?.message },
|
|
298
|
-
"claude stream run threw unexpectedly",
|
|
299
|
-
)
|
|
300
|
-
})
|
|
274
|
+
// per-message モデルでは attach 時点では query() を起動しない (即 ready)。
|
|
275
|
+
// 最初の input で query を起動する。
|
|
301
276
|
return { stream_id, resuming: !!resumeSessionId }
|
|
302
277
|
}
|
|
303
278
|
|
|
304
|
-
/** browser → claude の user メッセージ。
|
|
279
|
+
/** browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。 */
|
|
305
280
|
input({ stream_id, message }) {
|
|
306
281
|
const s = this.sessions.get(stream_id)
|
|
307
282
|
if (!s) {
|
|
308
283
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
309
284
|
return false
|
|
310
285
|
}
|
|
311
|
-
|
|
286
|
+
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
287
|
+
s.sendMessage(message).catch((err) => {
|
|
288
|
+
this.logger?.error(
|
|
289
|
+
{ stream_id, err: err?.message },
|
|
290
|
+
"claude sendMessage threw unexpectedly",
|
|
291
|
+
)
|
|
292
|
+
})
|
|
312
293
|
return true
|
|
313
294
|
}
|
|
314
295
|
|
|
@@ -325,21 +306,21 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
325
306
|
return s.resolvePermission(request_id, decision)
|
|
326
307
|
}
|
|
327
308
|
|
|
328
|
-
/**
|
|
309
|
+
/** 実行中ターンを中断 (セッションは生存、次の input は受付継続)。 */
|
|
329
310
|
interrupt({ stream_id }) {
|
|
330
311
|
const s = this.sessions.get(stream_id)
|
|
331
312
|
if (!s) return false
|
|
332
|
-
s.
|
|
313
|
+
s.abortTurn()
|
|
333
314
|
return true
|
|
334
315
|
}
|
|
335
316
|
|
|
336
|
-
/** セッション停止。Map
|
|
317
|
+
/** セッション停止。Map から即時削除し、実行中ターンを中断する。 */
|
|
337
318
|
detach({ stream_id }) {
|
|
338
319
|
const s = this.sessions.get(stream_id)
|
|
339
320
|
if (!s) return false
|
|
340
|
-
s.
|
|
341
|
-
// onExit を待たずに Map から外す (再 attach を即座に許可するため)
|
|
321
|
+
s.close()
|
|
342
322
|
this.sessions.delete(stream_id)
|
|
323
|
+
this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
|
|
343
324
|
return true
|
|
344
325
|
}
|
|
345
326
|
|