@cocorograph/hub-agent 0.5.9 → 0.5.11

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.9",
3
+ "version": "0.5.11",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
12
  */
13
- import { readFileSync } from "node:fs"
14
- import { readFile } from "node:fs/promises"
13
+ import { readFileSync, watch as fsWatch } from "node:fs"
14
+ import { mkdir, readFile, readdir } from "node:fs/promises"
15
15
  import os from "node:os"
16
16
  import path from "node:path"
17
17
 
@@ -119,11 +119,16 @@ export async function startDaemon({ version, ptyModule } = {}) {
119
119
  // 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
120
120
  // browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
121
121
  const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
122
+ // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
123
+ // に書き出す UserPromptSubmit / Stop の event を fs.watch で拾って WS push する。
124
+ // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
125
+ const sessionEventLoop = await startSessionEventWatcher({ client, logger })
122
126
 
123
127
  const shutdown = async (signal) => {
124
128
  logger.info({ signal }, "shutting down")
125
129
  await runHookBroadcast(plugins, "onAgentStop", ctx)
126
130
  stateLoop.stop()
131
+ sessionEventLoop?.stop?.()
127
132
  ptyBridge.shutdown()
128
133
  client.stop()
129
134
  process.exit(0)
@@ -134,6 +139,81 @@ export async function startDaemon({ version, ptyModule } = {}) {
134
139
  return { client, plugins, ptyBridge }
135
140
  }
136
141
 
142
+ const SESSION_EVENTS_DIR =
143
+ process.env.COCKPIT_SESSION_EVENTS_DIR || "/tmp/cockpit_session_events"
144
+
145
+ /**
146
+ * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
147
+ * UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
148
+ *
149
+ * 各 .json は `{ "event": "prompt_submit" | "stop", "at": <epoch_ms> }` 形式。
150
+ * - bundle 配信の `cockpit_session_event_hook.py` が claude code の hook 経由で
151
+ * ターン境界に書き換える
152
+ * - capture-pane の text 判定よりタイミングが正確で attach の影響を受けない
153
+ *
154
+ * ディレクトリが無ければ作成し、起動直後に既存ファイルを 1 回読んで初期 push。
155
+ * 以降は変更検知のたびに該当ファイルを読み直して push する。
156
+ */
157
+ async function startSessionEventWatcher({ client, logger }) {
158
+ try {
159
+ await mkdir(SESSION_EVENTS_DIR, { recursive: true })
160
+ } catch (err) {
161
+ logger?.warn(
162
+ { err: err.message, dir: SESSION_EVENTS_DIR },
163
+ "session event watcher: failed to mkdir, watcher disabled",
164
+ )
165
+ return null
166
+ }
167
+
168
+ const pushFile = async (filename) => {
169
+ if (!filename || !filename.endsWith(".json")) return
170
+ const sessionName = filename.slice(0, -5)
171
+ const full = path.join(SESSION_EVENTS_DIR, filename)
172
+ try {
173
+ const text = await readFile(full, "utf-8")
174
+ const data = JSON.parse(text)
175
+ if (!data || typeof data.event !== "string") return
176
+ client.send({
177
+ type: "session.event",
178
+ session_name: sessionName,
179
+ event: data.event,
180
+ at: typeof data.at === "number" ? data.at : Date.now(),
181
+ })
182
+ } catch {
183
+ // 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
184
+ }
185
+ }
186
+
187
+ // 起動時に既存ファイルを 1 回読んで初期 push (再起動後に取りこぼし防止)
188
+ try {
189
+ const files = await readdir(SESSION_EVENTS_DIR)
190
+ for (const f of files) await pushFile(f)
191
+ } catch {
192
+ // 無視
193
+ }
194
+
195
+ let watcher = null
196
+ try {
197
+ watcher = fsWatch(SESSION_EVENTS_DIR, { persistent: false }, (_event, filename) => {
198
+ if (!filename) return
199
+ // 名前文字列で来るので path.basename 不要
200
+ pushFile(filename).catch(() => {})
201
+ })
202
+ } catch (err) {
203
+ logger?.warn(
204
+ { err: err.message, dir: SESSION_EVENTS_DIR },
205
+ "session event watcher: fs.watch failed, watcher disabled",
206
+ )
207
+ return null
208
+ }
209
+
210
+ return {
211
+ stop() {
212
+ try { watcher?.close() } catch {}
213
+ },
214
+ }
215
+ }
216
+
137
217
  /**
138
218
  * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
139
219
  *
package/src/tmux.mjs CHANGED
@@ -366,6 +366,19 @@ export async function createSession(name, cwd, opts = {}) {
366
366
  // has-session が非 0 = セッション無し
367
367
  }
368
368
  await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
369
+ // Cockpit web UI からの touch swipe (= SGR wheel escape) を tmux に拾わせるため、
370
+ // 当該 session で mouse mode を ON にする。tmux 2.1+ で `mouse` option は
371
+ // session-scoped、`-t <session>` 指定で他 session への副作用なし。
372
+ // ユーザーの ~/.tmux.conf を編集せずに済む方式。失敗してもセッション起動自体は
373
+ // 妨げないように warn のみで握り潰す。
374
+ try {
375
+ await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
376
+ } catch (err) {
377
+ opts.logger?.warn?.(
378
+ { session: name, err: err?.message || String(err) },
379
+ "tmux set-option mouse on failed (cockpit touch scroll may not work in this session)",
380
+ )
381
+ }
369
382
  // 起動直後 pane の current_path を確認し、想定 cwd と異なれば WARN ログを残す
370
383
  // (Phase 3: 任意検証。tmux 側のクオート / 権限問題で fallback した場合を検知する)
371
384
  if (opts.logger) {