@cocorograph/hub-agent 0.5.32 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.32",
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
- export async function startDaemon({ version, ptyModule } = {}) {
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", {