@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
3
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 経由
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
- /** Claude `system/init` イベントで返してくる session_id を保持する。
68
- * resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
67
+ /** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
69
68
  this.sessionId = resumeSessionId || null
70
69
 
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 応答待ち */
70
+ /** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
76
71
  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
- }
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, _extra) {
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
- /** 非同期で 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
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
- const generator = this.sdk.query({
187
- prompt: this._promptIterator(),
188
- options,
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
- // system/init で session_id が確定する。resume 用に保持。
193
- if (msg?.type === "system" && msg?.subtype === "init" && typeof msg.session_id === "string") {
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._aborted) {
207
- code = 130
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._finished = true
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.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
- )
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
- // 非同期で 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
- })
274
+ // per-message モデルでは attach 時点では query() を起動しない (即 ready)。
275
+ // 最初の input で query を起動する。
301
276
  return { stream_id, resuming: !!resumeSessionId }
302
277
  }
303
278
 
304
- /** browser → claude の user メッセージ。message SDKUserMessage message フィールド (`{ role, content }`)。 */
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
- s.pushInput(message)
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
- /** turn 中断 (AbortController)。セッションは exit に到達する。 */
309
+ /** 実行中ターンを中断 (セッションは生存、次の input は受付継続)。 */
329
310
  interrupt({ stream_id }) {
330
311
  const s = this.sessions.get(stream_id)
331
312
  if (!s) return false
332
- s.abort()
313
+ s.abortTurn()
333
314
  return true
334
315
  }
335
316
 
336
- /** セッション停止。Map から即時削除し、abort で SDK ループを解放する。 */
317
+ /** セッション停止。Map から即時削除し、実行中ターンを中断する。 */
337
318
  detach({ stream_id }) {
338
319
  const s = this.sessions.get(stream_id)
339
320
  if (!s) return false
340
- s.abort()
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