@cocorograph/hub-agent 0.7.104 → 0.8.0

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.
@@ -1,280 +0,0 @@
1
- /**
2
- * TUI 権限ブリッジ (G3 spike)。
3
- *
4
- * 対話 TUI (tmux/PTY) で動く claude の PreToolUse フックが書き出す権限要求
5
- * ファイルを fs.watch で拾い、既存 SDK 経路と同じ `claude.permission.request`
6
- * として browser へ push する。browser の `claude.permission.reply` 決定を
7
- * `.decision` ファイルとして書き戻し、フックがそれを読んで permissionDecision
8
- * を返す。これにより TUI 自身の権限メニューを抑止しつつチャット UI で承認できる。
9
- *
10
- * ファイル IPC を採用する理由: hub-agent はローカル HTTP サーバを持たず、
11
- * `/tmp/cockpit_session_events/` の fs.watch 前例 (main.mjs) があるため、同じ
12
- * パターンに揃える。atomic write (tmp+rename) / 全エラー握りつぶしも踏襲する。
13
- *
14
- * 契約: D00000_hub.cocorograph.com:tools/cockpit-tui-chat/SPIKE-G3.md
15
- */
16
-
17
- import { EventEmitter } from "node:events"
18
- import { watch, existsSync } from "node:fs"
19
- import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
20
- import path from "node:path"
21
-
22
- export const PERMISSION_REQUESTS_DIR =
23
- process.env.COCKPIT_PERMISSION_DIR || "/tmp/cockpit_permission_requests"
24
-
25
- /**
26
- * pending request の TTL。これを超えた seen/pending エントリは GC する。
27
- *
28
- * 旧 5 分から延長 (6h)。理由 2 点:
29
- * - 遅延回答 resume: フックが死んだ後でもユーザーが戻って回答→新メッセージで再開できる
30
- * よう、回答可能な状態を長く保つ (resolve は 2s の .decision 未消費判定で resume へ倒す
31
- * ので、_pending を長く保っても二重確定は起きない)。
32
- * - 承認カードの re-hydrate: セッション切替でビューが再マウントしてもカードを復元できるよう、
33
- * 未応答リクエストのペイロードを保持しておく (listPending)。
34
- */
35
- const PENDING_TTL_MS = Number(process.env.COCKPIT_PENDING_TTL_MS) || 6 * 60 * 60 * 1000
36
-
37
- /**
38
- * TUI フック ⇄ browser の権限往復をファイル IPC で仲介する。
39
- *
40
- * イベント:
41
- * - "permission": { request_id, session_id, cwd, tool_name, input }
42
- * フックが書いた要求を 1 件読んだとき。main.mjs がこれを
43
- * `claude.permission.request` として client.send する。
44
- */
45
- export class TuiPermissionBridge extends EventEmitter {
46
- /**
47
- * @param {{dir?: string, logger?: object}} [opts]
48
- */
49
- constructor({ dir = PERMISSION_REQUESTS_DIR, logger } = {}) {
50
- super()
51
- this.dir = dir
52
- this.logger = logger
53
- /** @type {Map<string, {payload: object, at: number}>} request_id -> ペイロード + 受理時刻(ms) */
54
- this._pending = new Map()
55
- /** @type {Set<string>} 二重発火防止 (request_id) */
56
- this._seen = new Set()
57
- this._watcher = null
58
- }
59
-
60
- /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
61
- async start() {
62
- await mkdir(this.dir, { recursive: true })
63
- await this._sweep()
64
- try {
65
- this._watcher = watch(this.dir, (_event, filename) => {
66
- if (filename && filename.endsWith(".json")) {
67
- this._ingest(filename).catch(() => {})
68
- }
69
- })
70
- } catch (err) {
71
- this.logger?.warn(
72
- { err: err?.message },
73
- "tui permission watcher: fs.watch failed, watcher disabled",
74
- )
75
- }
76
- }
77
-
78
- /** 起動時 / 取りこぼし保険: dir 内の全 .json 要求を読む。 */
79
- async _sweep() {
80
- let names = []
81
- try {
82
- names = await readdir(this.dir)
83
- } catch {
84
- return
85
- }
86
- for (const n of names) {
87
- if (n.endsWith(".json")) await this._ingest(n).catch(() => {})
88
- }
89
- }
90
-
91
- /**
92
- * 要求ファイル 1 件を読み "permission" を emit する。partial write や
93
- * 既読は無視する。
94
- * @param {string} filename `<request_id>.json`
95
- */
96
- async _ingest(filename) {
97
- const request_id = filename.slice(0, -".json".length)
98
- if (this._seen.has(request_id)) return
99
- let body
100
- try {
101
- body = JSON.parse(await readFile(path.join(this.dir, filename), "utf-8"))
102
- } catch {
103
- // partial write / 既に消えた → 次の watch イベントで拾い直す。
104
- return
105
- }
106
- this._gc()
107
- this._seen.add(request_id)
108
- const payload = {
109
- request_id,
110
- session_id: body.session_id ?? null,
111
- cwd: body.cwd ?? null,
112
- tool_name: body.tool_name ?? "",
113
- input: body.tool_input ?? {},
114
- // 質問/承認カードの直前アシスタント説明 (フックが transcript から抽出して同梱)。
115
- // browser はこれをカード上部に表示し、jsonl tail の到着レースに依らず文脈を読める。
116
- context_text: body.context_text ?? null,
117
- }
118
- // payload も保持する (セッション切替でビュー再マウント時の re-hydrate / listPending 用)。
119
- this._pending.set(request_id, { payload, at: Date.now() })
120
- this.emit("permission", payload)
121
- }
122
-
123
- /**
124
- * 指定セッション宛の未応答リクエストのペイロード一覧を返す (re-hydrate 用)。
125
- *
126
- * セッション切替でビューが再マウントすると、その時点で持っていた承認/質問カードは
127
- * React state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
128
- * 戻ってきたときに復元できるよう、browser が再購読時にこれを引いて未応答カードを
129
- * 再配信する。session_id 一致を主、cwd 一致を従にして絞る。
130
- *
131
- * @param {{session_id?: string|null, cwd?: string|null}} q
132
- * @returns {object[]} emit と同形のペイロード配列
133
- */
134
- listPending({ session_id, cwd } = {}) {
135
- const out = []
136
- for (const { payload } of this._pending.values()) {
137
- const sidOk = session_id && payload.session_id === session_id
138
- const cwdOk = cwd && payload.cwd === cwd
139
- if (sidOk || cwdOk) out.push(payload)
140
- }
141
- return out
142
- }
143
-
144
- /** request を pending から完全に落とす (resume 等で「もう再配信しない」とき)。 */
145
- drop(request_id) {
146
- this._pending.delete(request_id)
147
- this._seen.delete(request_id)
148
- }
149
-
150
- /**
151
- * セッションに紐づく pending を **まとめて** 落とす (set/clear 非対称の解消)。
152
- *
153
- * `_pending` を畳む経路は元々 `resolve()`(browser 回答) と `drop(request_id)` の 2 本しか
154
- * 無く、(a) ターミナルでネイティブ回答された / (b) `/clear` で session が回転した /
155
- * (c) kill された、という「browser を経由しない終端」では pending が 6h TTL まで残り、
156
- * 復帰時 `listPending`→rehydrate が古いカードを反復再 push していた (症状1b)。本メソッドは
157
- * その終端を session 同一性で一括 drop するためのもの。
158
- *
159
- * マッチ規則は `listPending` (L134-142) と同じ OR セマンティクス: payload.session_id ===
160
- * session_id **または** payload.cwd === cwd。さらに `toolName` を渡すと payload.tool_name 一致
161
- * のものだけに絞る (ネイティブ回答検知で「いま解決したツールのカードだけ」を畳む用途)。
162
- * 落とした request_id を返すので、呼び出し側は browser へ `claude.permission.cancel` を
163
- * 送ってカードを消せる。
164
- *
165
- * `drop(request_id)` との違い: drop は request_id 既知の 1 件 (resume 経路)、dropBySession は
166
- * session 同一性で選んだ **集合** を落とし、落とした id 一覧を返す。
167
- *
168
- * @param {{session_id?: string|null, cwd?: string|null, toolName?: string|null}} q
169
- * @returns {string[]} 落とした request_id の配列
170
- */
171
- dropBySession({ session_id = null, cwd = null, toolName = null } = {}) {
172
- const dropped = []
173
- if (!session_id && !cwd) return dropped
174
- for (const [request_id, { payload }] of this._pending) {
175
- const sidOk = session_id && payload.session_id === session_id
176
- const cwdOk = cwd && payload.cwd === cwd
177
- if (!(sidOk || cwdOk)) continue
178
- if (toolName && payload.tool_name !== toolName) continue
179
- this._pending.delete(request_id)
180
- this._seen.delete(request_id)
181
- // 要求ファイルも掃除する (1-B: 質問系フックは即 abstain して .json を残すため、
182
- // 畳んだ後に消さないと agent 再起動時の _sweep が古いカードを再 emit する)。best-effort。
183
- unlink(path.join(this.dir, `${request_id}.json`)).catch(() => {})
184
- dropped.push(request_id)
185
- }
186
- return dropped
187
- }
188
-
189
- /**
190
- * browser の決定をフックへ返す。対象 request_id なら `.decision` を atomic
191
- * 書き込みし、要求ファイルを掃除して true を返す。対象外なら false
192
- * (呼び出し側は SDK 経路へフォールバックする)。
193
- *
194
- * @param {string} request_id
195
- * @param {boolean} allow
196
- * @param {string} [denyMessage]
197
- * @param {unknown} [updatedInput] AskUserQuestion 等の回答 ({questions, answers})。
198
- * フックが PreToolUse の hookSpecificOutput.updatedInput として注入し、claude が
199
- * 対話 UI を出さず確定する。Bash 等の allow/deny だけで済むツールでは undefined。
200
- * @returns {Promise<boolean>} この bridge の管理下だったか
201
- */
202
- async resolve(request_id, allow, denyMessage, updatedInput) {
203
- if (!this._pending.has(request_id)) return false
204
- this._pending.delete(request_id)
205
- const decision = allow ? "allow" : "deny"
206
- const fp = path.join(this.dir, `${request_id}.decision`)
207
- const tmp = `${fp}.tmp`
208
- try {
209
- await writeFile(
210
- tmp,
211
- JSON.stringify({
212
- decision,
213
- deny_message: denyMessage ?? null,
214
- // 回答 (AskUserQuestion の {questions, answers} 等)。フックが
215
- // updatedInput として注入する。無ければ null (Bash 等)。
216
- updated_input: updatedInput ?? null,
217
- }),
218
- )
219
- await rename(tmp, fp)
220
- } catch (err) {
221
- // 書けなくてもこの request は我々のもの: フックは TTL で ask 縮退する。
222
- this.logger?.warn({ err: err?.message, request_id }, "decision write failed")
223
- return true
224
- }
225
- try {
226
- await unlink(path.join(this.dir, `${request_id}.json`))
227
- } catch {
228
- /* フック側でも消す。二重防御。 */
229
- }
230
- return true
231
- }
232
-
233
- /**
234
- * 書き込んだ ``.decision`` がまだ消費されていないか判定する (遅延回答 resume 用)。
235
- *
236
- * フックが生存していれば ~0.15s のポーリングで ``.decision`` を読んで即 unlink する
237
- * (_poll_decision の _cleanup)。書き込み後しばらく経っても ``.decision`` が残っている
238
- * = フックは既に死んでいる (Claude Code のフック timeout で kill / TTL 経過で abstain
239
- * 済み) と判定でき、呼び出し側は「回答を新メッセージとしてセッションへ届ける resume」
240
- * へフォールバックする。
241
- *
242
- * @param {string} request_id
243
- * @returns {boolean} ``.decision`` が残っていれば true (= 未消費 = フック死亡)
244
- */
245
- hasUnconsumedDecision(request_id) {
246
- return existsSync(path.join(this.dir, `${request_id}.decision`))
247
- }
248
-
249
- /** 未消費の ``.decision`` を掃除する (resume へフォールバックしたとき呼ぶ)。 */
250
- async dropDecision(request_id) {
251
- try {
252
- await unlink(path.join(this.dir, `${request_id}.decision`))
253
- } catch {
254
- /* 既に無ければ no-op */
255
- }
256
- }
257
-
258
- /** TTL 超過の seen/pending を掃除する。 */
259
- _gc() {
260
- const now = Date.now()
261
- for (const [id, entry] of this._pending) {
262
- if (now - entry.at > PENDING_TTL_MS) {
263
- this._pending.delete(id)
264
- this._seen.delete(id)
265
- }
266
- }
267
- }
268
-
269
- /** watcher を閉じる。 */
270
- stop() {
271
- try {
272
- this._watcher?.close()
273
- } catch {
274
- /* ignore */
275
- }
276
- this._watcher = null
277
- }
278
- }
279
-
280
- export default TuiPermissionBridge
@@ -1,201 +0,0 @@
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
- /** atomic write の tmp 名を一意化するための単調増加カウンタ (race 回避)。 */
70
- this._tmpSeq = 0
71
- }
72
-
73
- /** ディレクトリを作成し、起動時の残骸を掃除して周期 sweep を張る。 */
74
- async start() {
75
- try {
76
- await mkdir(this.dir, { recursive: true })
77
- } catch (err) {
78
- this.logger?.warn({ err: err?.message }, "tui viewer registry: mkdir failed")
79
- }
80
- await this._sweep()
81
- this._sweepTimer = setInterval(() => {
82
- this._sweep().catch(() => {})
83
- }, SWEEP_INTERVAL_MS)
84
- // 他の timer 同様 unref して agent のアイドル終了を妨げない。
85
- this._sweepTimer?.unref?.()
86
- }
87
-
88
- /**
89
- * 閲覧ハートビートを受けてマーカーを更新する。session_id マーカー (厳密) と
90
- * cwd マーカー (フォールバック) の両方を ``expires_at = now + ttl`` で atomic 更新する。
91
- *
92
- * @param {{session_id?: string|null, cwd?: string|null}} info
93
- * @returns {Promise<void>}
94
- */
95
- async note({ session_id, cwd } = {}) {
96
- // expires_at は epoch **秒** (フック側 time.time() と比較するため)。
97
- const expiresAt = Date.now() / 1000 + this.ttlSec
98
- const updatedAt = Date.now() / 1000
99
- const body = JSON.stringify({
100
- session_id: session_id ?? null,
101
- cwd: cwd ?? null,
102
- expires_at: expiresAt,
103
- updated_at: updatedAt,
104
- })
105
- const targets = []
106
- if (session_id) {
107
- const safe = sanitizeSessionId(session_id)
108
- if (safe) targets.push(path.join(this.dir, `sid-${safe}.json`))
109
- }
110
- if (cwd) targets.push(path.join(this.dir, `cwd-${hashCwd(cwd)}.json`))
111
- for (const fp of targets) {
112
- await this._atomicWrite(fp, body)
113
- }
114
- }
115
-
116
- /**
117
- * 閲覧終了を受けて session_id マーカーを即削除する。cwd マーカーは同 cwd を
118
- * 他のブラウザが閲覧している可能性があるため即削除せず、TTL 失効に委ねる。
119
- *
120
- * @param {{session_id?: string|null}} info
121
- * @returns {Promise<void>}
122
- */
123
- async unview({ session_id } = {}) {
124
- if (!session_id) return
125
- const safe = sanitizeSessionId(session_id)
126
- if (!safe) return
127
- try {
128
- await unlink(path.join(this.dir, `sid-${safe}.json`))
129
- } catch {
130
- /* 既に無い / 失効済み。 */
131
- }
132
- }
133
-
134
- /**
135
- * atomic write (tmp+rename)。書けなくても agent は落とさない。
136
- *
137
- * tmp 名は ``<fp>.<pid>.<seq>.tmp`` で **書込ごとに一意化**する。固定 ``<fp>.tmp`` を
138
- * 使うと、同一マーカー (同 session_id / 同 cwd) へ複数タブ・高頻度ハートビートが並行
139
- * note() したとき、先勝ちの rename が tmp を移動した後に後発の rename が ENOENT で
140
- * 失敗する (rename '<fp>.tmp' → '<fp>': no such file)。マーカー自体は勝者が書くので
141
- * 致命的ではないが、ログを汚し鮮度更新を取りこぼす余地があった。一意 tmp なら各書込が
142
- * 独立した tmp を rename するため衝突しない。
143
- *
144
- * @param {string} fp
145
- * @param {string} body
146
- */
147
- async _atomicWrite(fp, body) {
148
- const tmp = `${fp}.${process.pid}.${++this._tmpSeq}.tmp`
149
- try {
150
- await writeFile(tmp, body)
151
- await rename(tmp, fp)
152
- } catch (err) {
153
- // rename 前に書けた tmp が残りうるので掃除する (sweep は *.json のみ対象)。
154
- try {
155
- await unlink(tmp)
156
- } catch {
157
- /* 無ければ no-op */
158
- }
159
- this.logger?.warn({ err: err?.message, fp }, "viewer marker write failed")
160
- }
161
- }
162
-
163
- /** expires_at を過ぎたマーカーを unlink する (衛生のみ)。 */
164
- async _sweep() {
165
- let names = []
166
- try {
167
- names = await readdir(this.dir)
168
- } catch {
169
- return
170
- }
171
- const now = Date.now() / 1000
172
- for (const n of names) {
173
- if (!n.endsWith(".json")) continue
174
- const fp = path.join(this.dir, n)
175
- let body
176
- try {
177
- body = JSON.parse(await readFile(fp, "utf-8"))
178
- } catch {
179
- continue
180
- }
181
- const exp = body?.expires_at
182
- if (typeof exp === "number" && exp <= now) {
183
- try {
184
- await unlink(fp)
185
- } catch {
186
- /* ignore */
187
- }
188
- }
189
- }
190
- }
191
-
192
- /** 周期 sweep を停止する。 */
193
- stop() {
194
- if (this._sweepTimer) {
195
- clearInterval(this._sweepTimer)
196
- this._sweepTimer = null
197
- }
198
- }
199
- }
200
-
201
- export default TuiViewerRegistry