@cocorograph/hub-agent 0.6.4 → 0.6.6
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 +132 -8
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
|
+
}
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
import { EventEmitter } from "node:events"
|
|
22
22
|
import { randomUUID } from "node:crypto"
|
|
23
23
|
|
|
24
|
+
import { jsonlPath } from "./claude-history.mjs"
|
|
25
|
+
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
26
|
+
|
|
24
27
|
/** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
|
|
25
28
|
function extractPromptText(message) {
|
|
26
29
|
if (typeof message === "string") return message
|
|
@@ -52,6 +55,7 @@ class ClaudeStreamSession {
|
|
|
52
55
|
onPermission,
|
|
53
56
|
onExit,
|
|
54
57
|
onError,
|
|
58
|
+
onReap,
|
|
55
59
|
}) {
|
|
56
60
|
this.stream_id = stream_id
|
|
57
61
|
this.cwd = cwd
|
|
@@ -63,6 +67,8 @@ class ClaudeStreamSession {
|
|
|
63
67
|
this.onPermission = onPermission
|
|
64
68
|
this.onExit = onExit
|
|
65
69
|
this.onError = onError
|
|
70
|
+
/** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
|
|
71
|
+
this.onReap = onReap
|
|
66
72
|
|
|
67
73
|
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
68
74
|
this.sessionId = resumeSessionId || null
|
|
@@ -73,8 +79,54 @@ class ClaudeStreamSession {
|
|
|
73
79
|
this._busy = false
|
|
74
80
|
/** detach 済みフラグ (新規ターン受付停止) */
|
|
75
81
|
this._closed = false
|
|
82
|
+
/** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
|
|
83
|
+
* 端末スリープ / ネット断で browser が切れても、明示的な中断 (interrupt) が
|
|
84
|
+
* 無い限りターンを落とさず最後まで走らせるために使う。 */
|
|
85
|
+
this._reapAfterTurn = false
|
|
76
86
|
/** 現在ターンの AbortController (interrupt 用) */
|
|
77
87
|
this._abortController = null
|
|
88
|
+
|
|
89
|
+
/** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
|
|
90
|
+
this._watcher = null
|
|
91
|
+
/** watcher が監視中の session_id (変化時に張り替え) */
|
|
92
|
+
this._watchedSessionId = null
|
|
93
|
+
// resume セッションがあれば即 watch 開始 (閲覧中の外部進行をライブ反映)
|
|
94
|
+
if (this.sessionId) this._ensureWatch()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 現在の sessionId の jsonl を watch する (既に同じものを watch 中なら何もしない)。 */
|
|
98
|
+
_ensureWatch() {
|
|
99
|
+
if (this._closed || !this.sessionId || !this.cwd) return
|
|
100
|
+
if (this._watchedSessionId === this.sessionId && this._watcher) return
|
|
101
|
+
// 旧 watcher を畳む
|
|
102
|
+
if (this._watcher) {
|
|
103
|
+
try {
|
|
104
|
+
this._watcher.stop()
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
this._watcher = null
|
|
109
|
+
}
|
|
110
|
+
const filePath = jsonlPath({ cwd: this.cwd, session_id: this.sessionId })
|
|
111
|
+
this._watchedSessionId = this.sessionId
|
|
112
|
+
this._watcher = watchSessionFile({
|
|
113
|
+
filePath,
|
|
114
|
+
fromEnd: true, // 監視開始時点の既存内容は history hydrate 済み。新規追記のみ拾う
|
|
115
|
+
logger: this.logger,
|
|
116
|
+
onEvent: (event) => {
|
|
117
|
+
// 自分の query 実行中 (busy) は query stream が同じ内容を流すため push しない
|
|
118
|
+
// (二重表示防止)。ターン完了時に skipToEnd で読み飛ばす。
|
|
119
|
+
if (this._busy) return
|
|
120
|
+
try {
|
|
121
|
+
this.onEvent?.(event)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.logger?.warn(
|
|
124
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
125
|
+
"watch onEvent threw",
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
})
|
|
78
130
|
}
|
|
79
131
|
|
|
80
132
|
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
@@ -145,6 +197,8 @@ class ClaudeStreamSession {
|
|
|
145
197
|
typeof msg.session_id === "string"
|
|
146
198
|
) {
|
|
147
199
|
this.sessionId = msg.session_id
|
|
200
|
+
// session_id が確定/変化したら watch をその jsonl に張り替える
|
|
201
|
+
this._ensureWatch()
|
|
148
202
|
}
|
|
149
203
|
// result イベントでも session_id が来ることがある (念のため拾う)
|
|
150
204
|
if (msg?.type === "result" && typeof msg.session_id === "string") {
|
|
@@ -170,7 +224,6 @@ class ClaudeStreamSession {
|
|
|
170
224
|
}
|
|
171
225
|
}
|
|
172
226
|
} finally {
|
|
173
|
-
this._busy = false
|
|
174
227
|
this._abortController = null
|
|
175
228
|
// 未解決 permission は閉じる
|
|
176
229
|
for (const [, resolver] of this._permissionResolvers) {
|
|
@@ -181,9 +234,35 @@ class ClaudeStreamSession {
|
|
|
181
234
|
}
|
|
182
235
|
}
|
|
183
236
|
this._permissionResolvers.clear()
|
|
237
|
+
// このターンで jsonl に書かれた行は query stream で既に push 済みなので、
|
|
238
|
+
// watcher の offset を末尾に飛ばして二重 push を防ぐ。busy=true のまま
|
|
239
|
+
// skipToEnd を待ち、完了後に busy=false にすることで、その間に watcher poll が
|
|
240
|
+
// 走ってもターン行を push しない (二重表示防止)。
|
|
241
|
+
this._ensureWatch()
|
|
242
|
+
if (this._watcher?.skipToEnd) {
|
|
243
|
+
try {
|
|
244
|
+
await this._watcher.skipToEnd()
|
|
245
|
+
} catch {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this._busy = false
|
|
184
250
|
if (aborted) {
|
|
185
251
|
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
186
252
|
}
|
|
253
|
+
// graceful detach: browser が切れている間にターンが完走したら、ここで遅延
|
|
254
|
+
// クローズする。manager 側で sessions Map から撤去 + exit を emit する。
|
|
255
|
+
if (this._reapAfterTurn && !this._closed) {
|
|
256
|
+
this.logger?.info(
|
|
257
|
+
{ stream_id: this.stream_id },
|
|
258
|
+
"claude turn finished after detach, reaping session",
|
|
259
|
+
)
|
|
260
|
+
try {
|
|
261
|
+
this.close()
|
|
262
|
+
} finally {
|
|
263
|
+
this.onReap?.()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
187
266
|
}
|
|
188
267
|
}
|
|
189
268
|
|
|
@@ -198,10 +277,32 @@ class ClaudeStreamSession {
|
|
|
198
277
|
}
|
|
199
278
|
}
|
|
200
279
|
|
|
201
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* graceful detach: 実行中ターンがあれば中断せず完走を待つ (完走後に finally で
|
|
282
|
+
* 自動クローズ + onReap)。アイドルなら即クローズする。
|
|
283
|
+
* @returns {boolean} 即時クローズしたら true / 完走待ちにしたら false
|
|
284
|
+
*/
|
|
285
|
+
gracefulClose() {
|
|
286
|
+
if (this._busy) {
|
|
287
|
+
this._reapAfterTurn = true
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
this.close()
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
202
295
|
close() {
|
|
203
296
|
this._closed = true
|
|
204
297
|
this.abortTurn()
|
|
298
|
+
if (this._watcher) {
|
|
299
|
+
try {
|
|
300
|
+
this._watcher.stop()
|
|
301
|
+
} catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
this._watcher = null
|
|
305
|
+
}
|
|
205
306
|
for (const [, resolver] of this._permissionResolvers) {
|
|
206
307
|
try {
|
|
207
308
|
resolver.resolve({ behavior: "deny", message: "closed" })
|
|
@@ -265,6 +366,18 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
265
366
|
onError: (err) => {
|
|
266
367
|
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
267
368
|
},
|
|
369
|
+
onReap: () => {
|
|
370
|
+
// ターン完走後の遅延クローズ。Map から撤去し exit を emit する。
|
|
371
|
+
if (this.sessions.get(stream_id) === session) {
|
|
372
|
+
this.sessions.delete(stream_id)
|
|
373
|
+
}
|
|
374
|
+
this.emit("exit", {
|
|
375
|
+
stream_id,
|
|
376
|
+
code: 0,
|
|
377
|
+
reason: "detached-after-turn",
|
|
378
|
+
session_id: session.sessionId,
|
|
379
|
+
})
|
|
380
|
+
},
|
|
268
381
|
})
|
|
269
382
|
this.sessions.set(stream_id, session)
|
|
270
383
|
this.logger?.info(
|
|
@@ -314,20 +427,31 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
314
427
|
return true
|
|
315
428
|
}
|
|
316
429
|
|
|
317
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
|
|
432
|
+
* onReap で Map から撤去する。アイドルなら即時撤去。
|
|
433
|
+
* 端末スリープ / ネット断による browser 切断でもターンを落とさないための入口。
|
|
434
|
+
* 明示的に止めたい場合は interrupt() を使う。
|
|
435
|
+
*/
|
|
318
436
|
detach({ stream_id }) {
|
|
319
437
|
const s = this.sessions.get(stream_id)
|
|
320
438
|
if (!s) return false
|
|
321
|
-
s.
|
|
322
|
-
|
|
323
|
-
|
|
439
|
+
const reaped = s.gracefulClose()
|
|
440
|
+
if (reaped) {
|
|
441
|
+
this.sessions.delete(stream_id)
|
|
442
|
+
this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
|
|
443
|
+
}
|
|
324
444
|
return true
|
|
325
445
|
}
|
|
326
446
|
|
|
327
|
-
/**
|
|
447
|
+
/** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
|
|
328
448
|
shutdown() {
|
|
329
449
|
for (const stream_id of Array.from(this.sessions.keys())) {
|
|
330
|
-
this.
|
|
450
|
+
const s = this.sessions.get(stream_id)
|
|
451
|
+
if (!s) continue
|
|
452
|
+
s.close()
|
|
453
|
+
this.sessions.delete(stream_id)
|
|
454
|
+
this.emit("exit", { stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
|
|
331
455
|
}
|
|
332
456
|
}
|
|
333
457
|
|