@cocorograph/hub-agent 0.6.42 → 0.6.43

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.42",
3
+ "version": "0.6.43",
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
@@ -49,6 +49,7 @@ import {
49
49
  listWorktreeStubs,
50
50
  removeWorktree as removeWorktreeDir,
51
51
  } from "./tmux.mjs"
52
+ import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
52
53
  import {
53
54
  contextWindowSize,
54
55
  getSessionUsages,
@@ -400,11 +401,41 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
400
401
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
401
402
  const sessionEventLoop = await startSessionEventWatcher({ client, logger })
402
403
 
404
+ // TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
405
+ // 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
406
+ // 決定 (claude.permission.reply) は dispatch 側で request_id を見て
407
+ // tuiPermissionBridge.resolve() に優先ルーティングする。
408
+ const tuiPermissionBridge = new TuiPermissionBridge({ logger })
409
+ tuiPermissionBridge.on(
410
+ "permission",
411
+ ({ request_id, session_id, cwd, tool_name, input }) => {
412
+ if (cwd) {
413
+ try {
414
+ recordChatActivity(cwd, { inputPending: true })
415
+ } catch {
416
+ /* ignore */
417
+ }
418
+ }
419
+ client.send({
420
+ type: "claude.permission.request",
421
+ stream_id: null,
422
+ session_id,
423
+ cwd,
424
+ request_id,
425
+ tool_name,
426
+ input,
427
+ })
428
+ },
429
+ )
430
+ await tuiPermissionBridge.start()
431
+ ctx.tuiPermissionBridge = tuiPermissionBridge
432
+
403
433
  const shutdown = async (signal) => {
404
434
  logger.info({ signal }, "shutting down")
405
435
  await runHookBroadcast(plugins, "onAgentStop", ctx)
406
436
  stateLoop.stop()
407
437
  sessionEventLoop?.stop?.()
438
+ tuiPermissionBridge.stop()
408
439
  ptyBridge.shutdown()
409
440
  claudeBridge?.shutdown?.()
410
441
  client.stop()
@@ -960,7 +991,17 @@ async function dispatch(msg, ctx) {
960
991
  }
961
992
  return
962
993
  }
963
- case "claude.permission.reply":
994
+ case "claude.permission.reply": {
995
+ // TUI ブリッジの pending なら .decision を書いて返す (TUI 経路優先)。
996
+ // 管理外 (= SDK チャットの request) なら従来の permissionReply へ。
997
+ const handledByTui = ctx.tuiPermissionBridge
998
+ ? await ctx.tuiPermissionBridge.resolve(
999
+ msg.request_id,
1000
+ !!msg.allow,
1001
+ msg.deny_message,
1002
+ )
1003
+ : false
1004
+ if (handledByTui) return
964
1005
  if (!ctx.claudeBridge) return
965
1006
  ctx.claudeBridge.permissionReply({
966
1007
  stream_id: msg.stream_id,
@@ -970,6 +1011,7 @@ async function dispatch(msg, ctx) {
970
1011
  denyMessage: msg.deny_message,
971
1012
  })
972
1013
  return
1014
+ }
973
1015
  case "claude.interrupt":
974
1016
  if (!ctx.claudeBridge) return
975
1017
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
@@ -0,0 +1,166 @@
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 } 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
+ /** pending request の TTL。これを超えた seen エントリは GC して再発火を許す。 */
26
+ const PENDING_TTL_MS = 5 * 60 * 1000
27
+
28
+ /**
29
+ * TUI フック ⇄ browser の権限往復をファイル IPC で仲介する。
30
+ *
31
+ * イベント:
32
+ * - "permission": { request_id, session_id, cwd, tool_name, input }
33
+ * フックが書いた要求を 1 件読んだとき。main.mjs がこれを
34
+ * `claude.permission.request` として client.send する。
35
+ */
36
+ export class TuiPermissionBridge extends EventEmitter {
37
+ /**
38
+ * @param {{dir?: string, logger?: object}} [opts]
39
+ */
40
+ constructor({ dir = PERMISSION_REQUESTS_DIR, logger } = {}) {
41
+ super()
42
+ this.dir = dir
43
+ this.logger = logger
44
+ /** @type {Map<string, number>} request_id -> 受理時刻(ms) */
45
+ this._pending = new Map()
46
+ /** @type {Set<string>} 二重発火防止 (request_id) */
47
+ this._seen = new Set()
48
+ this._watcher = null
49
+ }
50
+
51
+ /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
52
+ async start() {
53
+ await mkdir(this.dir, { recursive: true })
54
+ await this._sweep()
55
+ try {
56
+ this._watcher = watch(this.dir, (_event, filename) => {
57
+ if (filename && filename.endsWith(".json")) {
58
+ this._ingest(filename).catch(() => {})
59
+ }
60
+ })
61
+ } catch (err) {
62
+ this.logger?.warn(
63
+ { err: err?.message },
64
+ "tui permission watcher: fs.watch failed, watcher disabled",
65
+ )
66
+ }
67
+ }
68
+
69
+ /** 起動時 / 取りこぼし保険: dir 内の全 .json 要求を読む。 */
70
+ async _sweep() {
71
+ let names = []
72
+ try {
73
+ names = await readdir(this.dir)
74
+ } catch {
75
+ return
76
+ }
77
+ for (const n of names) {
78
+ if (n.endsWith(".json")) await this._ingest(n).catch(() => {})
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 要求ファイル 1 件を読み "permission" を emit する。partial write や
84
+ * 既読は無視する。
85
+ * @param {string} filename `<request_id>.json`
86
+ */
87
+ async _ingest(filename) {
88
+ const request_id = filename.slice(0, -".json".length)
89
+ if (this._seen.has(request_id)) return
90
+ let body
91
+ try {
92
+ body = JSON.parse(await readFile(path.join(this.dir, filename), "utf-8"))
93
+ } catch {
94
+ // partial write / 既に消えた → 次の watch イベントで拾い直す。
95
+ return
96
+ }
97
+ this._gc()
98
+ this._seen.add(request_id)
99
+ this._pending.set(request_id, Date.now())
100
+ this.emit("permission", {
101
+ request_id,
102
+ session_id: body.session_id ?? null,
103
+ cwd: body.cwd ?? null,
104
+ tool_name: body.tool_name ?? "",
105
+ input: body.tool_input ?? {},
106
+ })
107
+ }
108
+
109
+ /**
110
+ * browser の決定をフックへ返す。対象 request_id なら `.decision` を atomic
111
+ * 書き込みし、要求ファイルを掃除して true を返す。対象外なら false
112
+ * (呼び出し側は SDK 経路へフォールバックする)。
113
+ *
114
+ * @param {string} request_id
115
+ * @param {boolean} allow
116
+ * @param {string} [denyMessage]
117
+ * @returns {Promise<boolean>} この bridge の管理下だったか
118
+ */
119
+ async resolve(request_id, allow, denyMessage) {
120
+ if (!this._pending.has(request_id)) return false
121
+ this._pending.delete(request_id)
122
+ const decision = allow ? "allow" : "deny"
123
+ const fp = path.join(this.dir, `${request_id}.decision`)
124
+ const tmp = `${fp}.tmp`
125
+ try {
126
+ await writeFile(
127
+ tmp,
128
+ JSON.stringify({ decision, deny_message: denyMessage ?? null }),
129
+ )
130
+ await rename(tmp, fp)
131
+ } catch (err) {
132
+ // 書けなくてもこの request は我々のもの: フックは TTL で ask 縮退する。
133
+ this.logger?.warn({ err: err?.message, request_id }, "decision write failed")
134
+ return true
135
+ }
136
+ try {
137
+ await unlink(path.join(this.dir, `${request_id}.json`))
138
+ } catch {
139
+ /* フック側でも消す。二重防御。 */
140
+ }
141
+ return true
142
+ }
143
+
144
+ /** TTL 超過の seen/pending を掃除する。 */
145
+ _gc() {
146
+ const now = Date.now()
147
+ for (const [id, at] of this._pending) {
148
+ if (now - at > PENDING_TTL_MS) {
149
+ this._pending.delete(id)
150
+ this._seen.delete(id)
151
+ }
152
+ }
153
+ }
154
+
155
+ /** watcher を閉じる。 */
156
+ stop() {
157
+ try {
158
+ this._watcher?.close()
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ this._watcher = null
163
+ }
164
+ }
165
+
166
+ export default TuiPermissionBridge