@cocorograph/hub-agent 0.6.41 → 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 +1 -1
- package/src/claude-stream-bridge.mjs +81 -3
- package/src/main.mjs +43 -1
- package/src/tui-permission-bridge.mjs +166 -0
package/package.json
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { EventEmitter } from "node:events"
|
|
22
22
|
import { randomUUID } from "node:crypto"
|
|
23
|
+
import { readFileSync } from "node:fs"
|
|
24
|
+
import os from "node:os"
|
|
25
|
+
import path from "node:path"
|
|
23
26
|
|
|
24
27
|
import { jsonlPath } from "./claude-history.mjs"
|
|
25
28
|
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
@@ -65,6 +68,36 @@ const MCP_CONTROL_IDLE_MS = Number(
|
|
|
65
68
|
process.env.HUB_AGENT_MCP_CONTROL_IDLE_MS || 5 * 60 * 1000,
|
|
66
69
|
)
|
|
67
70
|
|
|
71
|
+
/** claude が使う設定ディレクトリ (CLAUDE_CONFIG_DIR 優先、無ければ ~/.claude)。 */
|
|
72
|
+
function claudeConfigDir() {
|
|
73
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* claude の「要認証 (needs-auth)」キャッシュからサーバー名一覧を読む。
|
|
78
|
+
*
|
|
79
|
+
* `<configDir>/mcp-needs-auth-cache.json` は claude が「認証が必要」と判定した MCP
|
|
80
|
+
* サーバー (claude.ai リモートコネクター含む) を記録するファイルで、`{ "<server名>":
|
|
81
|
+
* { timestamp, id? }, ... }` 形式。**一度も接続したことがない** コネクターも含まれる。
|
|
82
|
+
*
|
|
83
|
+
* 長寿命プロセスの control query は claude のコネクター列挙キャッシュ挙動により、
|
|
84
|
+
* never-connected な claude.ai コネクターを `mcpServerStatus()` に列挙しそびれること
|
|
85
|
+
* があるため、このファイルを補完ソースに使う (mcpStatus でマージ)。
|
|
86
|
+
*
|
|
87
|
+
* @returns {string[]} 認証待ちサーバー名の配列 (読めない / 不正なら空配列)
|
|
88
|
+
*/
|
|
89
|
+
function readNeedsAuthCacheNames() {
|
|
90
|
+
try {
|
|
91
|
+
const p = path.join(claudeConfigDir(), "mcp-needs-auth-cache.json")
|
|
92
|
+
const obj = JSON.parse(readFileSync(p, "utf8"))
|
|
93
|
+
if (!obj || typeof obj !== "object") return []
|
|
94
|
+
return Object.keys(obj).filter((k) => typeof k === "string" && k.length > 0)
|
|
95
|
+
} catch {
|
|
96
|
+
// ファイル不在 / JSON 破損は無視 (補完なしで従来どおり動く)。
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
/** 多端末共有セッション (2026-05-30)。同じ Claude セッション (session_id) を複数端末で
|
|
69
102
|
* 同時に開いて、どの端末でもライブ表示・入力・許可応答できるようにする。env
|
|
70
103
|
* HUB_AGENT_CHAT_SHARED="1" で有効化 (デフォルト無効 = 従来の「最後の端末だけが live」)。
|
|
@@ -1061,13 +1094,15 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1061
1094
|
* (テストでは `{ query: stubQuery }` を渡す)
|
|
1062
1095
|
* @param {import('pino').Logger} [opts.logger]
|
|
1063
1096
|
*/
|
|
1064
|
-
constructor({ sdk, logger } = {}) {
|
|
1097
|
+
constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth } = {}) {
|
|
1065
1098
|
super()
|
|
1066
1099
|
if (!sdk || typeof sdk.query !== "function") {
|
|
1067
1100
|
throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
|
|
1068
1101
|
}
|
|
1069
1102
|
this.sdk = sdk
|
|
1070
1103
|
this.logger = logger
|
|
1104
|
+
/** needs-auth キャッシュ名読み取り (テスト差し替え用。既定は実ファイル読み込み)。 */
|
|
1105
|
+
this._readNeedsAuthCacheNames = readNeedsAuth || readNeedsAuthCacheNames
|
|
1071
1106
|
/** @type {Map<string, ClaudeStreamSession>} */
|
|
1072
1107
|
this.sessions = new Map()
|
|
1073
1108
|
/** session_id → 生存セッション。再接続 (再アタッチ) で同一セッションを引く索引。
|
|
@@ -1377,7 +1412,49 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1377
1412
|
if (opts.reload) this._teardownMcpControlQuery()
|
|
1378
1413
|
const q = await this._ensureMcpControlQuery()
|
|
1379
1414
|
this._armMcpControlIdle()
|
|
1380
|
-
|
|
1415
|
+
const servers = await q.mcpServerStatus()
|
|
1416
|
+
return this._mergeNeedsAuthCache(servers)
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
1421
|
+
* サーバーを `needs-auth` として補完する。
|
|
1422
|
+
*
|
|
1423
|
+
* 背景: 長寿命プロセスの control query は claude のコネクター列挙キャッシュ挙動に
|
|
1424
|
+
* より、「一度も接続したことがない」claude.ai コネクターを `mcpServerStatus()` に
|
|
1425
|
+
* 列挙しそびれることがある (新規プロセスでは列挙される)。その結果 Cockpit の一覧に
|
|
1426
|
+
* 出ず、初回接続を別経路 (ターミナル / claude.ai) で済ませるまで再認証導線が出ない
|
|
1427
|
+
* 問題があった。needs-auth キャッシュは never-connected も記録しているため、これを
|
|
1428
|
+
* マージして「要再認証」で常に一覧へ出し、その場で再認証 → 接続できるようにする。
|
|
1429
|
+
*
|
|
1430
|
+
* @param {Array<{name?: string, status?: string}>} servers - SDK の状態一覧
|
|
1431
|
+
* @returns {Array<{name: string, status: string}>} 補完後の一覧
|
|
1432
|
+
*/
|
|
1433
|
+
_mergeNeedsAuthCache(servers) {
|
|
1434
|
+
const list = Array.isArray(servers) ? servers.slice() : []
|
|
1435
|
+
try {
|
|
1436
|
+
const known = new Set(list.map((s) => s?.name).filter(Boolean))
|
|
1437
|
+
let added = 0
|
|
1438
|
+
for (const name of this._readNeedsAuthCacheNames()) {
|
|
1439
|
+
if (!name || known.has(name)) continue
|
|
1440
|
+
// SDK が列挙しなかった未接続コネクター。要再認証として補完する。
|
|
1441
|
+
list.push({ name, status: "needs-auth" })
|
|
1442
|
+
known.add(name)
|
|
1443
|
+
added += 1
|
|
1444
|
+
}
|
|
1445
|
+
if (added > 0) {
|
|
1446
|
+
this.logger?.info(
|
|
1447
|
+
{ added },
|
|
1448
|
+
"mcp status: surfaced never-connected servers from needs-auth cache",
|
|
1449
|
+
)
|
|
1450
|
+
}
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
this.logger?.warn(
|
|
1453
|
+
{ err: err?.message },
|
|
1454
|
+
"mcp needs-auth cache merge failed (ignored)",
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
return list
|
|
1381
1458
|
}
|
|
1382
1459
|
|
|
1383
1460
|
/** 指定 MCP サーバーを control 専用 query 内で再接続し、最新状態一覧を返す。 */
|
|
@@ -1385,7 +1462,8 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1385
1462
|
const q = await this._ensureMcpControlQuery()
|
|
1386
1463
|
this._armMcpControlIdle()
|
|
1387
1464
|
await q.reconnectMcpServer(serverName)
|
|
1388
|
-
|
|
1465
|
+
const servers = await q.mcpServerStatus()
|
|
1466
|
+
return this._mergeNeedsAuthCache(servers)
|
|
1389
1467
|
}
|
|
1390
1468
|
|
|
1391
1469
|
/** 指定 MCP サーバーを「動作中の全 resident セッション」+ control query で再接続する。
|
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
|