@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 +1 -1
- package/src/claude-history-watch.mjs +171 -0
- package/src/claude-stream-bridge.mjs +200 -152
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
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,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
|
-
/**
|
|
68
|
-
* resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
|
|
70
|
+
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
69
71
|
this.sessionId = resumeSessionId || null
|
|
70
72
|
|
|
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 応答待ち */
|
|
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
|
-
|
|
79
|
-
this.
|
|
80
|
-
|
|
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
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (this.
|
|
87
|
-
|
|
88
|
-
if (this.
|
|
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
|
-
|
|
97
|
+
this._watcher.stop()
|
|
118
98
|
} catch {
|
|
119
99
|
/* ignore */
|
|
120
100
|
}
|
|
101
|
+
this._watcher = null
|
|
121
102
|
}
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
207
|
-
|
|
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.
|
|
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.
|
|
222
|
-
} catch
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
//
|
|
295
|
-
|
|
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 メッセージ。
|
|
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
|
-
|
|
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
|
-
/**
|
|
376
|
+
/** 実行中ターンを中断 (セッションは生存、次の input は受付継続)。 */
|
|
329
377
|
interrupt({ stream_id }) {
|
|
330
378
|
const s = this.sessions.get(stream_id)
|
|
331
379
|
if (!s) return false
|
|
332
|
-
s.
|
|
380
|
+
s.abortTurn()
|
|
333
381
|
return true
|
|
334
382
|
}
|
|
335
383
|
|
|
336
|
-
/** セッション停止。Map
|
|
384
|
+
/** セッション停止。Map から即時削除し、実行中ターンを中断する。 */
|
|
337
385
|
detach({ stream_id }) {
|
|
338
386
|
const s = this.sessions.get(stream_id)
|
|
339
387
|
if (!s) return false
|
|
340
|
-
s.
|
|
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
|
|