@cocorograph/hub-agent 0.6.4 → 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.4",
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
+ }
@@ -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
@@ -75,6 +78,48 @@ class ClaudeStreamSession {
75
78
  this._closed = false
76
79
  /** 現在ターンの AbortController (interrupt 用) */
77
80
  this._abortController = null
81
+
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()
88
+ }
89
+
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) {
96
+ try {
97
+ this._watcher.stop()
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+ this._watcher = null
102
+ }
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
+ })
78
123
  }
79
124
 
80
125
  /** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
@@ -145,6 +190,8 @@ class ClaudeStreamSession {
145
190
  typeof msg.session_id === "string"
146
191
  ) {
147
192
  this.sessionId = msg.session_id
193
+ // session_id が確定/変化したら watch をその jsonl に張り替える
194
+ this._ensureWatch()
148
195
  }
149
196
  // result イベントでも session_id が来ることがある (念のため拾う)
150
197
  if (msg?.type === "result" && typeof msg.session_id === "string") {
@@ -170,7 +217,6 @@ class ClaudeStreamSession {
170
217
  }
171
218
  }
172
219
  } finally {
173
- this._busy = false
174
220
  this._abortController = null
175
221
  // 未解決 permission は閉じる
176
222
  for (const [, resolver] of this._permissionResolvers) {
@@ -181,6 +227,19 @@ class ClaudeStreamSession {
181
227
  }
182
228
  }
183
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
184
243
  if (aborted) {
185
244
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
186
245
  }
@@ -198,10 +257,18 @@ class ClaudeStreamSession {
198
257
  }
199
258
  }
200
259
 
201
- /** セッション終了。新規ターンを止め、実行中なら中断する。 */
260
+ /** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
202
261
  close() {
203
262
  this._closed = true
204
263
  this.abortTurn()
264
+ if (this._watcher) {
265
+ try {
266
+ this._watcher.stop()
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ this._watcher = null
271
+ }
205
272
  for (const [, resolver] of this._permissionResolvers) {
206
273
  try {
207
274
  resolver.resolve({ behavior: "deny", message: "closed" })