@cocorograph/hub-agent 0.6.3 → 0.6.5

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.5",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Claude Code セッション jsonl のライブ追従 watcher (Sprint G 0.6.5)。
3
+ *
4
+ * 用途: Cockpit ChatView で「外部 (tmux の Claude 等) が同じ session の jsonl に
5
+ * 追記した内容」をリアルタイムに反映する。per-message query は「自分が送った
6
+ * ターン」しか stream しないため、外部進行を拾うには jsonl を tail する必要がある。
7
+ *
8
+ * 設計:
9
+ * - 対象ファイルのバイトオフセットを記録し、増分だけ読んで行単位でパース
10
+ * - fs.watch(file) の change イベントで増分読み取り (取りこぼし対策に軽いポーリング併用)
11
+ * - DISPLAY_TYPES (user/assistant/system/result) の行だけ onEvent に渡す
12
+ * - 各行の uuid を含めて渡す (frontend 側で重複排除に使う)
13
+ * - ファイル不在時は出現を待つ (ディレクトリ監視はせず、ポーリングで存在チェック)
14
+ */
15
+ import { watch as fsWatch } from "node:fs"
16
+ import { open, stat } from "node:fs/promises"
17
+
18
+ const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
19
+ const POLL_INTERVAL_MS = 1500
20
+
21
+ /**
22
+ * 1 つの jsonl ファイルを tail する watcher を生成する。
23
+ *
24
+ * @param {object} args
25
+ * @param {string} args.filePath - 監視対象の jsonl 絶対パス
26
+ * @param {(event: object) => void} args.onEvent - DISPLAY_TYPES の行ごとに呼ばれる
27
+ * @param {boolean} [args.fromEnd=true] - 既存内容は飛ばし、監視開始後の追記のみ拾う
28
+ * (起動時の履歴は history.request で別途 hydrate 済みのため、tail は新規分だけでよい)
29
+ * @param {import('pino').Logger} [args.logger]
30
+ * @returns {{ stop: () => void }}
31
+ */
32
+ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger }) {
33
+ let offset = 0
34
+ let reading = false
35
+ let stopped = false
36
+ let leftover = ""
37
+ let fsWatcher = null
38
+ let pollTimer = null
39
+ let initialized = false
40
+
41
+ async function initOffset() {
42
+ try {
43
+ const st = await stat(filePath)
44
+ offset = fromEnd ? st.size : 0
45
+ initialized = true
46
+ } catch {
47
+ // ファイル未存在: offset=0 のまま、出現したら先頭から (fromEnd は初回出現には適用しない)
48
+ offset = 0
49
+ initialized = false
50
+ }
51
+ }
52
+
53
+ async function readIncrement() {
54
+ if (stopped || reading) return
55
+ reading = true
56
+ try {
57
+ let st
58
+ try {
59
+ st = await stat(filePath)
60
+ } catch {
61
+ return // まだ無い
62
+ }
63
+ if (!initialized) {
64
+ // 初回出現: fromEnd でも「出現直後の全文」は新規とみなして先頭から読む
65
+ // (監視開始時点で既存だったファイルは initOffset で末尾にセット済み)
66
+ initialized = true
67
+ }
68
+ if (st.size < offset) {
69
+ // truncate / rotate された → 先頭から読み直す
70
+ offset = 0
71
+ leftover = ""
72
+ }
73
+ if (st.size === offset) return
74
+ const fh = await open(filePath, "r")
75
+ try {
76
+ const len = st.size - offset
77
+ const buf = Buffer.alloc(len)
78
+ await fh.read(buf, 0, len, offset)
79
+ offset = st.size
80
+ const text = leftover + buf.toString("utf-8")
81
+ const lines = text.split("\n")
82
+ leftover = lines.pop() ?? "" // 最終要素は未完行として保持
83
+ for (const line of lines) {
84
+ if (!line) continue
85
+ let obj
86
+ try {
87
+ obj = JSON.parse(line)
88
+ } catch {
89
+ continue
90
+ }
91
+ if (!obj || !DISPLAY_TYPES.has(obj.type)) continue
92
+ const event = normalizeEvent(obj)
93
+ try {
94
+ onEvent(event)
95
+ } catch (err) {
96
+ logger?.warn({ err: err.message }, "watchSessionFile onEvent threw")
97
+ }
98
+ }
99
+ } finally {
100
+ await fh.close()
101
+ }
102
+ } catch (err) {
103
+ logger?.warn({ err: err.message, filePath }, "watchSessionFile read failed")
104
+ } finally {
105
+ reading = false
106
+ }
107
+ }
108
+
109
+ ;(async () => {
110
+ await initOffset()
111
+ if (stopped) return
112
+ // fs.watch (change で増分読み取り)
113
+ try {
114
+ fsWatcher = fsWatch(filePath, { persistent: false }, () => {
115
+ readIncrement().catch(() => {})
116
+ })
117
+ fsWatcher.on?.("error", () => {})
118
+ } catch {
119
+ // ファイル未存在等で watch 不可 → ポーリングに委ねる
120
+ }
121
+ // 取りこぼし / ファイル出現待ち用の軽いポーリング
122
+ pollTimer = setInterval(() => readIncrement().catch(() => {}), POLL_INTERVAL_MS)
123
+ pollTimer.unref?.()
124
+ })()
125
+
126
+ return {
127
+ stop() {
128
+ stopped = true
129
+ try {
130
+ fsWatcher?.close()
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ if (pollTimer) clearInterval(pollTimer)
135
+ },
136
+ /**
137
+ * offset を現在のファイル末尾に進める (= 未読分を push せず捨てる)。
138
+ * per-message query 実行中に書かれた行は query stream 側で既に browser に
139
+ * 届いているため、ターン完了時にこれを呼んで二重 push を防ぐ。
140
+ */
141
+ async skipToEnd() {
142
+ try {
143
+ const st = await stat(filePath)
144
+ offset = st.size
145
+ leftover = ""
146
+ initialized = true
147
+ } catch {
148
+ /* ファイル無し: 次の出現で先頭から */
149
+ }
150
+ },
151
+ }
152
+ }
153
+
154
+ /** jsonl の生 object を SDK message 風の表示用イベントに正規化 (history.mjs と揃える)。 */
155
+ function normalizeEvent(obj) {
156
+ const event = { type: obj.type }
157
+ if (obj.message !== undefined) event.message = obj.message
158
+ if (obj.subtype !== undefined) event.subtype = obj.subtype
159
+ if (obj.uuid !== undefined) event.uuid = obj.uuid
160
+ if (obj.session_id !== undefined) event.session_id = obj.session_id
161
+ else if (obj.sessionId !== undefined) event.session_id = obj.sessionId
162
+ if (obj.model !== undefined) event.model = obj.model
163
+ if (obj.cwd !== undefined) event.cwd = obj.cwd
164
+ if (obj.tools !== undefined) event.tools = obj.tools
165
+ if (obj.permissionMode !== undefined) event.permissionMode = obj.permissionMode
166
+ if (obj.total_cost_usd !== undefined) event.total_cost_usd = obj.total_cost_usd
167
+ if (obj.duration_ms !== undefined) event.duration_ms = obj.duration_ms
168
+ if (obj.num_turns !== undefined) event.num_turns = obj.num_turns
169
+ if (obj.usage !== undefined) event.usage = obj.usage
170
+ return event
171
+ }
@@ -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,28 @@
17
21
  import { EventEmitter } from "node:events"
18
22
  import { randomUUID } from "node:crypto"
19
23
 
24
+ import { jsonlPath } from "./claude-history.mjs"
25
+ import { watchSessionFile } from "./claude-history-watch.mjs"
26
+
27
+ /** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
28
+ function extractPromptText(message) {
29
+ if (typeof message === "string") return message
30
+ if (!message) return ""
31
+ const content = message.content
32
+ if (typeof content === "string") return content
33
+ if (Array.isArray(content)) {
34
+ return content
35
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
36
+ .map((b) => b.text)
37
+ .join("\n")
38
+ }
39
+ return ""
40
+ }
41
+
20
42
  /**
21
- * 1 stream に対応する Claude セッション。
22
- *
23
- * SDK の `query()` は prompt を AsyncIterable として受け取り、ユーザーメッセージを
24
- * 流し続ける限り interactive multi-turn として動作する。pushInput / endInput で
25
- * iterator の挙動を制御する。
43
+ * 1 stream に対応する Claude セッション (per-message query モデル)。
26
44
  */
27
45
  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
46
  constructor({
43
47
  stream_id,
44
48
  cwd,
@@ -56,7 +60,6 @@ class ClaudeStreamSession {
56
60
  this.cwd = cwd
57
61
  this.model = model || null
58
62
  this.permissionMode = permissionMode || null
59
- this.resumeSessionId = resumeSessionId || null
60
63
  this.sdk = sdk
61
64
  this.logger = logger
62
65
  this.onEvent = onEvent
@@ -64,93 +67,63 @@ class ClaudeStreamSession {
64
67
  this.onExit = onExit
65
68
  this.onError = onError
66
69
 
67
- /** Claude `system/init` イベントで返してくる session_id を保持する。
68
- * resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
70
+ /** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
69
71
  this.sessionId = resumeSessionId || null
70
72
 
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 応答待ち */
73
+ /** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
76
74
  this._permissionResolvers = new Map()
75
+ /** 現在ターン実行中か (多重 query 防止) */
76
+ this._busy = false
77
+ /** detach 済みフラグ (新規ターン受付停止) */
78
+ this._closed = false
79
+ /** 現在ターンの AbortController (interrupt 用) */
80
+ this._abortController = null
77
81
 
78
- this._aborted = false
79
- this._finished = false
80
- this._abortController = new AbortController()
82
+ /** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
83
+ this._watcher = null
84
+ /** watcher が監視中の session_id (変化時に張り替え) */
85
+ this._watchedSessionId = null
86
+ // resume セッションがあれば即 watch 開始 (閲覧中の外部進行をライブ反映)
87
+ if (this.sessionId) this._ensureWatch()
81
88
  }
82
89
 
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) {
90
+ /** 現在の sessionId jsonl watch する (既に同じものを watch 中なら何もしない) */
91
+ _ensureWatch() {
92
+ if (this._closed || !this.sessionId || !this.cwd) return
93
+ if (this._watchedSessionId === this.sessionId && this._watcher) return
94
+ // watcher を畳む
95
+ if (this._watcher) {
116
96
  try {
117
- resolver.resolve({ behavior: "deny", message: "aborted" })
97
+ this._watcher.stop()
118
98
  } catch {
119
99
  /* ignore */
120
100
  }
101
+ this._watcher = null
121
102
  }
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
- }
103
+ const filePath = jsonlPath({ cwd: this.cwd, session_id: this.sessionId })
104
+ this._watchedSessionId = this.sessionId
105
+ this._watcher = watchSessionFile({
106
+ filePath,
107
+ fromEnd: true, // 監視開始時点の既存内容は history hydrate 済み。新規追記のみ拾う
108
+ logger: this.logger,
109
+ onEvent: (event) => {
110
+ // 自分の query 実行中 (busy) query stream が同じ内容を流すため push しない
111
+ // (二重表示防止)。ターン完了時に skipToEnd で読み飛ばす。
112
+ if (this._busy) return
113
+ try {
114
+ this.onEvent?.(event)
115
+ } catch (err) {
116
+ this.logger?.warn(
117
+ { err: err.message, stream_id: this.stream_id },
118
+ "watch onEvent threw",
119
+ )
120
+ }
121
+ },
122
+ })
150
123
  }
151
124
 
152
125
  /** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
153
- async _canUseTool(toolName, input, _extra) {
126
+ async _canUseTool(toolName, input) {
154
127
  if (!this.onPermission) return { behavior: "allow", updatedInput: input }
155
128
  const request_id = randomUUID()
156
129
  return await new Promise((resolve) => {
@@ -168,29 +141,60 @@ class ClaudeStreamSession {
168
141
  })
169
142
  }
170
143
 
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
144
+ /** browser からの permission 応答を該当 request_id Promise に渡す。 */
145
+ resolvePermission(request_id, decision) {
146
+ const r = this._permissionResolvers.get(request_id)
147
+ if (!r) return false
148
+ this._permissionResolvers.delete(request_id)
149
+ r.resolve(decision)
150
+ return true
151
+ }
185
152
 
186
- const generator = this.sdk.query({
187
- prompt: this._promptIterator(),
188
- options,
189
- })
153
+ /**
154
+ * ユーザーメッセージ 1 件を 1 query() として実行する。
155
+ * 既存ターン実行中は無視 (UI 側で送信を抑止する想定だが念のため)。
156
+ */
157
+ async sendMessage(message) {
158
+ if (this._closed) return
159
+ if (this._busy) {
160
+ this.logger?.warn(
161
+ { stream_id: this.stream_id },
162
+ "claude session busy, message ignored",
163
+ )
164
+ return
165
+ }
166
+ const prompt = extractPromptText(message)
167
+ if (!prompt) return
168
+
169
+ this._busy = true
170
+ this._abortController = new AbortController()
171
+ let aborted = false
172
+
173
+ const options = {
174
+ cwd: this.cwd,
175
+ canUseTool: (toolName, input) => this._canUseTool(toolName, input),
176
+ includePartialMessages: true,
177
+ abortController: this._abortController,
178
+ }
179
+ if (this.model) options.model = this.model
180
+ if (this.permissionMode) options.permissionMode = this.permissionMode
181
+ // 直前ターンまでの session_id があれば resume チェーン
182
+ if (this.sessionId) options.resume = this.sessionId
190
183
 
184
+ try {
185
+ const generator = this.sdk.query({ prompt, options })
191
186
  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") {
187
+ if (
188
+ msg?.type === "system" &&
189
+ msg?.subtype === "init" &&
190
+ typeof msg.session_id === "string"
191
+ ) {
192
+ this.sessionId = msg.session_id
193
+ // session_id が確定/変化したら watch をその jsonl に張り替える
194
+ this._ensureWatch()
195
+ }
196
+ // result イベントでも session_id が来ることがある (念のため拾う)
197
+ if (msg?.type === "result" && typeof msg.session_id === "string") {
194
198
  this.sessionId = msg.session_id
195
199
  }
196
200
  try {
@@ -203,12 +207,9 @@ class ClaudeStreamSession {
203
207
  }
204
208
  }
205
209
  } catch (err) {
206
- if (this._aborted) {
207
- code = 130
208
- reason = "aborted"
210
+ if (this._abortController?.signal?.aborted) {
211
+ aborted = true
209
212
  } else {
210
- code = 1
211
- reason = err?.message || String(err)
212
213
  try {
213
214
  this.onError?.(err)
214
215
  } catch {
@@ -216,16 +217,66 @@ class ClaudeStreamSession {
216
217
  }
217
218
  }
218
219
  } finally {
219
- this._finished = true
220
+ this._abortController = null
221
+ // 未解決 permission は閉じる
222
+ for (const [, resolver] of this._permissionResolvers) {
223
+ try {
224
+ resolver.resolve({ behavior: "deny", message: "turn ended" })
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ }
229
+ this._permissionResolvers.clear()
230
+ // このターンで jsonl に書かれた行は query stream で既に push 済みなので、
231
+ // watcher の offset を末尾に飛ばして二重 push を防ぐ。busy=true のまま
232
+ // skipToEnd を待ち、完了後に busy=false にすることで、その間に watcher poll が
233
+ // 走ってもターン行を push しない (二重表示防止)。
234
+ this._ensureWatch()
235
+ if (this._watcher?.skipToEnd) {
236
+ try {
237
+ await this._watcher.skipToEnd()
238
+ } catch {
239
+ /* ignore */
240
+ }
241
+ }
242
+ this._busy = false
243
+ if (aborted) {
244
+ this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
245
+ }
246
+ }
247
+ }
248
+
249
+ /** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
250
+ abortTurn() {
251
+ if (this._abortController) {
220
252
  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
- )
253
+ this._abortController.abort()
254
+ } catch {
255
+ /* ignore */
256
+ }
257
+ }
258
+ }
259
+
260
+ /** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
261
+ close() {
262
+ this._closed = true
263
+ this.abortTurn()
264
+ if (this._watcher) {
265
+ try {
266
+ this._watcher.stop()
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ this._watcher = null
271
+ }
272
+ for (const [, resolver] of this._permissionResolvers) {
273
+ try {
274
+ resolver.resolve({ behavior: "deny", message: "closed" })
275
+ } catch {
276
+ /* ignore */
227
277
  }
228
278
  }
279
+ this._permissionResolvers.clear()
229
280
  }
230
281
  }
231
282
 
@@ -278,10 +329,6 @@ export class ClaudeStreamBridge extends EventEmitter {
278
329
  onPermission: ({ tool_name, input, request_id }) => {
279
330
  this.emit("permission", { stream_id, request_id, tool_name, input })
280
331
  },
281
- onExit: ({ code, reason, session_id }) => {
282
- this.sessions.delete(stream_id)
283
- this.emit("exit", { stream_id, code, reason, session_id })
284
- },
285
332
  onError: (err) => {
286
333
  this.emit("error", { stream_id, error: err?.message || String(err) })
287
334
  },
@@ -291,24 +338,25 @@ export class ClaudeStreamBridge extends EventEmitter {
291
338
  { stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
292
339
  "claude stream attached",
293
340
  )
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
- })
341
+ // per-message モデルでは attach 時点では query() を起動しない (即 ready)。
342
+ // 最初の input で query を起動する。
301
343
  return { stream_id, resuming: !!resumeSessionId }
302
344
  }
303
345
 
304
- /** browser → claude の user メッセージ。message SDKUserMessage message フィールド (`{ role, content }`)。 */
346
+ /** browser → claude の user メッセージ。1 = 1 query (resume チェーン)。 */
305
347
  input({ stream_id, message }) {
306
348
  const s = this.sessions.get(stream_id)
307
349
  if (!s) {
308
350
  this.logger?.warn({ stream_id }, "claude.input but stream missing")
309
351
  return false
310
352
  }
311
- s.pushInput(message)
353
+ // 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
354
+ s.sendMessage(message).catch((err) => {
355
+ this.logger?.error(
356
+ { stream_id, err: err?.message },
357
+ "claude sendMessage threw unexpectedly",
358
+ )
359
+ })
312
360
  return true
313
361
  }
314
362
 
@@ -325,21 +373,21 @@ export class ClaudeStreamBridge extends EventEmitter {
325
373
  return s.resolvePermission(request_id, decision)
326
374
  }
327
375
 
328
- /** turn 中断 (AbortController)。セッションは exit に到達する。 */
376
+ /** 実行中ターンを中断 (セッションは生存、次の input は受付継続)。 */
329
377
  interrupt({ stream_id }) {
330
378
  const s = this.sessions.get(stream_id)
331
379
  if (!s) return false
332
- s.abort()
380
+ s.abortTurn()
333
381
  return true
334
382
  }
335
383
 
336
- /** セッション停止。Map から即時削除し、abort で SDK ループを解放する。 */
384
+ /** セッション停止。Map から即時削除し、実行中ターンを中断する。 */
337
385
  detach({ stream_id }) {
338
386
  const s = this.sessions.get(stream_id)
339
387
  if (!s) return false
340
- s.abort()
341
- // onExit を待たずに Map から外す (再 attach を即座に許可するため)
388
+ s.close()
342
389
  this.sessions.delete(stream_id)
390
+ this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
343
391
  return true
344
392
  }
345
393