@cocorograph/hub-agent 0.6.43 → 0.6.45

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.43",
3
+ "version": "0.6.45",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,185 @@
1
+ /**
2
+ * jsonl ライブ追従 watcher の束ね役 (T04784 逐次表示)。
3
+ *
4
+ * Cockpit の TUI チャットは「対話 TUI (tmux/PTY) の claude が書く jsonl」を表示源にする。
5
+ * 従来は session.event (ターン境界) を合図に jsonl 全体を再 hydrate して「どさっと」更新
6
+ * していたが、ターン中の途中経過は 2.5s ポーリングで拾うしかなく、逐次性が弱かった。
7
+ *
8
+ * 本 manager は、ブラウザが送る `claude.tui.viewing {session_id, cwd}` ハートビート
9
+ * (= まさにその session を TUI チャットで閲覧している瞬間) を起点に、対象 session の
10
+ * jsonl を byte offset で tail し (claude-history-watch.mjs)、追記行を 1 行ずつ
11
+ * `claude.jsonl.event` として WS push する。これでターン中の本文・ツール結果が逐次
12
+ * ブラウザに届き、再 hydrate / ポーリングが不要になる。
13
+ *
14
+ * ライフサイクルは watcher ゲートの TuiViewerRegistry と相似形:
15
+ * - note(): 閲覧ハートビートで watcher を起動 or TTL 延長。
16
+ * - unwatch(): claude.tui.unviewing で即停止。
17
+ * - _sweep(): TTL 失効した watcher を掃除 (unviewing 取りこぼし対策)。
18
+ *
19
+ * 課金枠への影響なし: jsonl の読み取りはローカル FS の追従であり、モデル呼び出しを
20
+ * 伴わない (SDK query を起こさない)。TUI モード = サブスク枠のまま。
21
+ *
22
+ * fromEnd=true で監視開始時点の既存内容はスキップする。初回履歴は claude.history.request
23
+ * で別途 hydrate 済みのため、tail は新規分だけでよい (二重 push は frontend が uuid で排除)。
24
+ */
25
+
26
+ import { watchSessionFile } from "./claude-history-watch.mjs"
27
+ import { jsonlPath } from "./claude-history.mjs"
28
+
29
+ /** watcher 鮮度 (ms)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
30
+ const WATCHER_TTL_MS = Number(process.env.COCKPIT_JSONL_WATCH_TTL_MS) || 15_000
31
+ /** TTL 失効 watcher を掃除する周期 (ms)。 */
32
+ const SWEEP_INTERVAL_MS = 30 * 1000
33
+
34
+ /**
35
+ * session_id ごとに 1 つの jsonl tail watcher を保持し、閲覧ハートビートで延命する。
36
+ */
37
+ export class JsonlLiveWatchers {
38
+ /**
39
+ * @param {object} args
40
+ * @param {(obj: object) => unknown} args.send - WS 送信 (client.send バインド済み)
41
+ * @param {() => Promise<string|undefined>|string|undefined} args.getProjectsRoot
42
+ * - ~/.claude/projects の実効ルート解決 (アカウント切替を反映)
43
+ * @param {number} [args.ttlMs]
44
+ * @param {import('pino').Logger} [args.logger]
45
+ */
46
+ constructor({ send, getProjectsRoot, ttlMs = WATCHER_TTL_MS, logger } = {}) {
47
+ this.send = send
48
+ this.getProjectsRoot = getProjectsRoot
49
+ this.ttlMs = ttlMs
50
+ this.logger = logger
51
+ /** @type {Map<string, {watcher: {stop: () => void}, cwd: string, expiresAt: number}>} */
52
+ this._entries = new Map()
53
+ this._sweepTimer = null
54
+ /** 同一 session に対する note() の競合を直列化する (二重 watcher 防止)。 */
55
+ this._starting = new Set()
56
+ }
57
+
58
+ /** 周期 sweep を張る。 */
59
+ start() {
60
+ this._sweepTimer = setInterval(() => this._sweep(), SWEEP_INTERVAL_MS)
61
+ // 他 timer 同様 unref して agent のアイドル終了を妨げない。
62
+ this._sweepTimer?.unref?.()
63
+ }
64
+
65
+ /**
66
+ * 閲覧ハートビートを受けて watcher を起動 or 延命する。
67
+ *
68
+ * @param {{session_id?: string|null, cwd?: string|null}} info
69
+ * @returns {Promise<void>}
70
+ */
71
+ async note({ session_id, cwd } = {}) {
72
+ if (!session_id || !cwd) return
73
+ const existing = this._entries.get(session_id)
74
+ if (existing) {
75
+ // 既に追従中: TTL を延長するだけ (cwd が変わるのは想定外なので無視)。
76
+ existing.expiresAt = Date.now() + this.ttlMs
77
+ return
78
+ }
79
+ // 起動中の重複呼び出しを弾く (ハートビートが連続して来ても watcher は 1 本)。
80
+ if (this._starting.has(session_id)) return
81
+ this._starting.add(session_id)
82
+ try {
83
+ const projectsRoot = await this._resolveProjectsRoot()
84
+ // 起動処理中に別経路で登録済みになっていないか再チェック。
85
+ if (this._entries.has(session_id)) return
86
+ const filePath = jsonlPath({ cwd, session_id, projectsRoot })
87
+ const watcher = watchSessionFile({
88
+ filePath,
89
+ fromEnd: true,
90
+ logger: this.logger,
91
+ onEvent: (event) => {
92
+ try {
93
+ this.send?.({
94
+ type: "claude.jsonl.event",
95
+ session_id,
96
+ cwd,
97
+ event,
98
+ })
99
+ } catch (err) {
100
+ this.logger?.warn(
101
+ { err: err?.message },
102
+ "jsonl live watch send failed",
103
+ )
104
+ }
105
+ },
106
+ })
107
+ this._entries.set(session_id, {
108
+ watcher,
109
+ cwd,
110
+ expiresAt: Date.now() + this.ttlMs,
111
+ })
112
+ } catch (err) {
113
+ this.logger?.warn(
114
+ { err: err?.message, session_id },
115
+ "jsonl live watch start failed",
116
+ )
117
+ } finally {
118
+ this._starting.delete(session_id)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 閲覧終了を受けて watcher を即停止する。
124
+ *
125
+ * @param {{session_id?: string|null}} info
126
+ */
127
+ unwatch({ session_id } = {}) {
128
+ if (!session_id) return
129
+ const entry = this._entries.get(session_id)
130
+ if (!entry) return
131
+ this._entries.delete(session_id)
132
+ try {
133
+ entry.watcher.stop()
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+
139
+ async _resolveProjectsRoot() {
140
+ try {
141
+ const r = this.getProjectsRoot?.()
142
+ return r && typeof r.then === "function" ? await r : r
143
+ } catch {
144
+ return undefined
145
+ }
146
+ }
147
+
148
+ /** TTL を過ぎた watcher を停止・削除する (unviewing 取りこぼし対策)。 */
149
+ _sweep() {
150
+ const now = Date.now()
151
+ for (const [session_id, entry] of this._entries) {
152
+ if (entry.expiresAt <= now) {
153
+ this._entries.delete(session_id)
154
+ try {
155
+ entry.watcher.stop()
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** 全 watcher と sweep timer を停止する。 */
164
+ stop() {
165
+ if (this._sweepTimer) {
166
+ clearInterval(this._sweepTimer)
167
+ this._sweepTimer = null
168
+ }
169
+ for (const entry of this._entries.values()) {
170
+ try {
171
+ entry.watcher.stop()
172
+ } catch {
173
+ /* ignore */
174
+ }
175
+ }
176
+ this._entries.clear()
177
+ }
178
+
179
+ /** テスト用: 現在追従中の session_id 数。 */
180
+ get size() {
181
+ return this._entries.size
182
+ }
183
+ }
184
+
185
+ export default JsonlLiveWatchers
package/src/main.mjs CHANGED
@@ -50,6 +50,8 @@ import {
50
50
  removeWorktree as removeWorktreeDir,
51
51
  } from "./tmux.mjs"
52
52
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
53
+ import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
54
+ import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
53
55
  import {
54
56
  contextWindowSize,
55
57
  getSessionUsages,
@@ -430,12 +432,33 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
430
432
  await tuiPermissionBridge.start()
431
433
  ctx.tuiPermissionBridge = tuiPermissionBridge
432
434
 
435
+ // TUI 閲覧マーカー registry: browser の claude.tui.viewing ハートビートを受けて
436
+ // 「閲覧中の session」をローカルのマーカーファイルとして保持する。承認フックは
437
+ // その鮮度を見て「閲覧者がいる session だけブリッジ、いなければ即 ask」する
438
+ // (watcher ゲート, WATCHER-GATE.md)。
439
+ const tuiViewerRegistry = new TuiViewerRegistry({ logger })
440
+ await tuiViewerRegistry.start()
441
+ ctx.tuiViewerRegistry = tuiViewerRegistry
442
+
443
+ // jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
444
+ // session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
445
+ // これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
446
+ const jsonlLiveWatchers = new JsonlLiveWatchers({
447
+ send: (obj) => client.send(obj),
448
+ getProjectsRoot: getActiveProjectsRoot,
449
+ logger,
450
+ })
451
+ jsonlLiveWatchers.start()
452
+ ctx.jsonlLiveWatchers = jsonlLiveWatchers
453
+
433
454
  const shutdown = async (signal) => {
434
455
  logger.info({ signal }, "shutting down")
435
456
  await runHookBroadcast(plugins, "onAgentStop", ctx)
436
457
  stateLoop.stop()
437
458
  sessionEventLoop?.stop?.()
438
459
  tuiPermissionBridge.stop()
460
+ tuiViewerRegistry.stop()
461
+ jsonlLiveWatchers.stop()
439
462
  ptyBridge.shutdown()
440
463
  claudeBridge?.shutdown?.()
441
464
  client.stop()
@@ -1016,6 +1039,25 @@ async function dispatch(msg, ctx) {
1016
1039
  if (!ctx.claudeBridge) return
1017
1040
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
1018
1041
  return
1042
+ case "claude.tui.viewing":
1043
+ // T04778 watcher ゲート: browser が TUI チャットでこの session を閲覧中。
1044
+ // 閲覧マーカーを更新し、承認フックが「閲覧者あり」と判定できるようにする。
1045
+ ctx.tuiViewerRegistry
1046
+ ?.note({ session_id: msg.session_id, cwd: msg.cwd })
1047
+ .catch(() => {})
1048
+ // T04784 逐次表示: 閲覧中だけ jsonl を tail して claude.jsonl.event を push。
1049
+ ctx.jsonlLiveWatchers
1050
+ ?.note({ session_id: msg.session_id, cwd: msg.cwd })
1051
+ .catch(() => {})
1052
+ return
1053
+ case "claude.tui.unviewing":
1054
+ // 閲覧終了。session_id マーカーを即失効させ、以降の Bash を即 ask に倒す。
1055
+ ctx.tuiViewerRegistry
1056
+ ?.unview({ session_id: msg.session_id })
1057
+ .catch(() => {})
1058
+ // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
1059
+ ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
1060
+ return
1019
1061
  case "claude.mcp.status":
1020
1062
  case "claude.mcp.reconnect":
1021
1063
  case "claude.mcp.authenticate":
@@ -0,0 +1,185 @@
1
+ /**
2
+ * TUI 閲覧マーカー registry (T04778 watcher ゲート)。
3
+ *
4
+ * Cockpit の TUI チャットでブラウザが「ある session を閲覧中」であることを、
5
+ * ローカルのマーカーファイルとして保持する。対話 TUI (tmux/PTY) で動く claude の
6
+ * PreToolUse 承認フック (bridge_permission.py) は、ツール実行前にこのマーカーの
7
+ * 鮮度を読み、「閲覧者がいる session だけ承認を Cockpit へブリッジ、いなければ
8
+ * 即 ask 縮退」する。これにより、承認フックを全ユーザーの settings.json に常設
9
+ * しても、非閲覧の対話セッション (リーダー / cockpit-orch サブ / ターミナルモード)
10
+ * の Bash がブリッジ応答 TTL ぶんハングする地雷を回避する。
11
+ *
12
+ * ブラウザは閲覧中 `claude.tui.viewing {session_id, cwd}` を周期送信し、main.mjs が
13
+ * note() を呼んでマーカーを更新する。アンマウント時は `claude.tui.unviewing` で
14
+ * unview() を呼び、sid マーカーを即失効させる。鮮度切れは sweep で掃除する。
15
+ *
16
+ * ファイル IPC / atomic write (tmp+rename) / 全エラー握りつぶしは
17
+ * tui-permission-bridge.mjs の前例に揃える。
18
+ *
19
+ * 契約: D00000_hub.cocorograph.com:tools/cockpit-tui-chat/WATCHER-GATE.md
20
+ */
21
+
22
+ import { createHash } from "node:crypto"
23
+ import { mkdir, writeFile, rename, unlink, readdir, readFile } from "node:fs/promises"
24
+ import path from "node:path"
25
+
26
+ export const VIEWERS_DIR =
27
+ process.env.COCKPIT_VIEWERS_DIR || "/tmp/cockpit_tui_viewers"
28
+
29
+ /** マーカー鮮度 (秒)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
30
+ const VIEWER_TTL_SEC = Number(process.env.COCKPIT_VIEWER_TTL_SEC) || 15
31
+
32
+ /** 失効マーカーを掃除する周期 (ms)。判定はフック側が expires_at で行うので衛生のみ。 */
33
+ const SWEEP_INTERVAL_MS = 30 * 1000
34
+
35
+ /**
36
+ * session_id をフック側 (`bridge_permission.py`) と同一規則でファイル名へ正規化する。
37
+ * Python 側は ``c.isalnum() or c in "-_"`` を残す。UUID は ASCII 英数 + ハイフンなので
38
+ * 実質 no-op だが、両者が一致しないとマーカーを引けないため厳密に揃える。
39
+ *
40
+ * @param {string} sessionId
41
+ * @returns {string}
42
+ */
43
+ function sanitizeSessionId(sessionId) {
44
+ return String(sessionId).replace(/[^A-Za-z0-9_-]/g, "")
45
+ }
46
+
47
+ /**
48
+ * cwd をフック側と同一規則でハッシュする (sha1 hex 先頭 16 文字)。
49
+ *
50
+ * @param {string} cwd
51
+ * @returns {string}
52
+ */
53
+ function hashCwd(cwd) {
54
+ return createHash("sha1").update(String(cwd), "utf8").digest("hex").slice(0, 16)
55
+ }
56
+
57
+ /**
58
+ * TUI 閲覧マーカーをローカル FS に保持する registry。
59
+ */
60
+ export class TuiViewerRegistry {
61
+ /**
62
+ * @param {{dir?: string, ttlSec?: number, logger?: object}} [opts]
63
+ */
64
+ constructor({ dir = VIEWERS_DIR, ttlSec = VIEWER_TTL_SEC, logger } = {}) {
65
+ this.dir = dir
66
+ this.ttlSec = ttlSec
67
+ this.logger = logger
68
+ this._sweepTimer = null
69
+ }
70
+
71
+ /** ディレクトリを作成し、起動時の残骸を掃除して周期 sweep を張る。 */
72
+ async start() {
73
+ try {
74
+ await mkdir(this.dir, { recursive: true })
75
+ } catch (err) {
76
+ this.logger?.warn({ err: err?.message }, "tui viewer registry: mkdir failed")
77
+ }
78
+ await this._sweep()
79
+ this._sweepTimer = setInterval(() => {
80
+ this._sweep().catch(() => {})
81
+ }, SWEEP_INTERVAL_MS)
82
+ // 他の timer 同様 unref して agent のアイドル終了を妨げない。
83
+ this._sweepTimer?.unref?.()
84
+ }
85
+
86
+ /**
87
+ * 閲覧ハートビートを受けてマーカーを更新する。session_id マーカー (厳密) と
88
+ * cwd マーカー (フォールバック) の両方を ``expires_at = now + ttl`` で atomic 更新する。
89
+ *
90
+ * @param {{session_id?: string|null, cwd?: string|null}} info
91
+ * @returns {Promise<void>}
92
+ */
93
+ async note({ session_id, cwd } = {}) {
94
+ // expires_at は epoch **秒** (フック側 time.time() と比較するため)。
95
+ const expiresAt = Date.now() / 1000 + this.ttlSec
96
+ const updatedAt = Date.now() / 1000
97
+ const body = JSON.stringify({
98
+ session_id: session_id ?? null,
99
+ cwd: cwd ?? null,
100
+ expires_at: expiresAt,
101
+ updated_at: updatedAt,
102
+ })
103
+ const targets = []
104
+ if (session_id) {
105
+ const safe = sanitizeSessionId(session_id)
106
+ if (safe) targets.push(path.join(this.dir, `sid-${safe}.json`))
107
+ }
108
+ if (cwd) targets.push(path.join(this.dir, `cwd-${hashCwd(cwd)}.json`))
109
+ for (const fp of targets) {
110
+ await this._atomicWrite(fp, body)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 閲覧終了を受けて session_id マーカーを即削除する。cwd マーカーは同 cwd を
116
+ * 他のブラウザが閲覧している可能性があるため即削除せず、TTL 失効に委ねる。
117
+ *
118
+ * @param {{session_id?: string|null}} info
119
+ * @returns {Promise<void>}
120
+ */
121
+ async unview({ session_id } = {}) {
122
+ if (!session_id) return
123
+ const safe = sanitizeSessionId(session_id)
124
+ if (!safe) return
125
+ try {
126
+ await unlink(path.join(this.dir, `sid-${safe}.json`))
127
+ } catch {
128
+ /* 既に無い / 失効済み。 */
129
+ }
130
+ }
131
+
132
+ /**
133
+ * atomic write (tmp+rename)。書けなくても agent は落とさない。
134
+ * @param {string} fp
135
+ * @param {string} body
136
+ */
137
+ async _atomicWrite(fp, body) {
138
+ const tmp = `${fp}.tmp`
139
+ try {
140
+ await writeFile(tmp, body)
141
+ await rename(tmp, fp)
142
+ } catch (err) {
143
+ this.logger?.warn({ err: err?.message, fp }, "viewer marker write failed")
144
+ }
145
+ }
146
+
147
+ /** expires_at を過ぎたマーカーを unlink する (衛生のみ)。 */
148
+ async _sweep() {
149
+ let names = []
150
+ try {
151
+ names = await readdir(this.dir)
152
+ } catch {
153
+ return
154
+ }
155
+ const now = Date.now() / 1000
156
+ for (const n of names) {
157
+ if (!n.endsWith(".json")) continue
158
+ const fp = path.join(this.dir, n)
159
+ let body
160
+ try {
161
+ body = JSON.parse(await readFile(fp, "utf-8"))
162
+ } catch {
163
+ continue
164
+ }
165
+ const exp = body?.expires_at
166
+ if (typeof exp === "number" && exp <= now) {
167
+ try {
168
+ await unlink(fp)
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ /** 周期 sweep を停止する。 */
177
+ stop() {
178
+ if (this._sweepTimer) {
179
+ clearInterval(this._sweepTimer)
180
+ this._sweepTimer = null
181
+ }
182
+ }
183
+ }
184
+
185
+ export default TuiViewerRegistry