@cocorograph/hub-agent 0.7.57 → 0.7.59
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/codex-mcp-control.mjs +313 -0
- package/src/hub-bundle.mjs +26 -8
- package/src/main.mjs +77 -3
package/package.json
CHANGED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex の MCP サーバー状態 control (agent-global, チャットセッション非依存)。
|
|
3
|
+
*
|
|
4
|
+
* Claude 側 (claude-stream-bridge.mjs の「MCP control 専用 query」) と同じ役割を Codex で
|
|
5
|
+
* 果たす。MCP サーバーは `~/.codex/config.toml` = アカウント単位の設定で全セッション共有
|
|
6
|
+
* のため、チャットの stream_id / thread に紐付けず agent-global な control クライアントで
|
|
7
|
+
* 扱い、アイドルで破棄する。
|
|
8
|
+
*
|
|
9
|
+
* ── 2026-07-14 の実機検証で確定した Codex app-server の仕様 ──────────────────
|
|
10
|
+
*
|
|
11
|
+
* 1. `mcpServerStatus/list` は **生存状態を返さない**。返るのは
|
|
12
|
+
* `{name, serverInfo, tools, resources, resourceTemplates, authStatus}` で、
|
|
13
|
+
* `authStatus` は「認証方式」(unsupported | notLoggedIn | bearerToken | oAuth) であって
|
|
14
|
+
* 「今つながっているか」ではない。実際、OAuth 期限切れで接続に失敗している hub も
|
|
15
|
+
* `authStatus: "oAuth"` を返す (成功している hub_staff と同値) ため、これ単独では
|
|
16
|
+
* Claude の `status` 相当を作れない。
|
|
17
|
+
*
|
|
18
|
+
* 2. 生存状態は `mcpServer/startupStatus/updated` 通知
|
|
19
|
+
* (`{name, status: starting|ready|failed|cancelled, error, threadId}`) でのみ得られる。
|
|
20
|
+
* ⚠️ この通知は **thread を start しないと一切飛ばない** (initialize だけでは無音)。
|
|
21
|
+
* そのため control クライアントは「捨てスレッド」を 1 本張って MCP 起動を誘発し、
|
|
22
|
+
* 通知を集約する。turn は開始しないのでトークン消費は無い。
|
|
23
|
+
*
|
|
24
|
+
* 3. `detail: "toolsAndAuthOnly"` でも応答は 1MB 超になりうる (tools の inputSchema が
|
|
25
|
+
* そのまま載る。実測 1.2MB / 421 tools)。**WS に載せる前に必ず要約する** こと。
|
|
26
|
+
*
|
|
27
|
+
* 4. `mcpServer/oauth/login` は認可 URL を即返す (実測 0.2s)。ブラウザは codex 側では
|
|
28
|
+
* 開かず、クライアント (Cockpit) が開く前提。コールバックは codex がホスト上の
|
|
29
|
+
* ループバック (127.0.0.1:<port>/callback/<id>) で待ち受け、完了は
|
|
30
|
+
* `mcpServer/oauthLogin/completed` 通知で push される。**Claude と違いコールバック URL の
|
|
31
|
+
* 貼り付けは不要** なので、UI にはブローカー型 (callbackExpected: false) として返す。
|
|
32
|
+
* ⚠️ 裏を返すと、Cockpit をスマホで開いている場合はループバックに着地できないため
|
|
33
|
+
* デスクトップからの再認証のみ成立する (既知の制約)。
|
|
34
|
+
*/
|
|
35
|
+
import os from "node:os"
|
|
36
|
+
|
|
37
|
+
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
38
|
+
|
|
39
|
+
/** control クライアントのアイドル破棄時間 (Claude 側の MCP_CONTROL_IDLE_MS と対称)。 */
|
|
40
|
+
const IDLE_MS = Number(process.env.HUB_AGENT_MCP_CONTROL_IDLE_MS || 5 * 60 * 1000)
|
|
41
|
+
|
|
42
|
+
/** 起動通知が落ち着くまでの静穏時間 / 全体の上限。 */
|
|
43
|
+
const SETTLE_QUIET_MS = 1_200
|
|
44
|
+
const SETTLE_MAX_MS = 25_000
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Codex の startup 状態 + list エントリを Cockpit の `McpServerStatus` へ正規化する。
|
|
48
|
+
*
|
|
49
|
+
* フロントの型 (`useCockpitMcpServers.ts` の McpServerStatus) は Claude SDK 由来だが、
|
|
50
|
+
* 同じ形に寄せることで Popover / 再認証 UI をそのまま再利用できる。
|
|
51
|
+
*
|
|
52
|
+
* `needs-auth` の判定が肝: Codex は「OAuth が要る」を専用の状態では返さず、
|
|
53
|
+
* `failed` + エラー文言 ("OAuth authorization required" 等) で表現する。ここを
|
|
54
|
+
* `failed` のままにすると UI が「再接続」ボタンを出してしまい、何度押しても直らない
|
|
55
|
+
* (OAuth フローに入れない) ため、認証由来の失敗は `needs-auth` に倒す。
|
|
56
|
+
*
|
|
57
|
+
* @param {{name: string, serverInfo?: object|null, tools?: object|null, authStatus?: string}} entry
|
|
58
|
+
* @param {{status?: string, error?: string|null}|undefined} startup
|
|
59
|
+
* @returns {{name: string, status: string, serverInfo?: object, error?: string, tools: {name: string}[]}}
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeCodexMcpServer(entry, startup) {
|
|
62
|
+
const name = entry?.name
|
|
63
|
+
const authStatus = entry?.authStatus
|
|
64
|
+
const startupState = startup?.status
|
|
65
|
+
const startupError = startup?.error || undefined
|
|
66
|
+
|
|
67
|
+
// tools は {toolName: {name, description, inputSchema...}} のマップで来る。
|
|
68
|
+
// inputSchema を載せると WS が 1MB 超になるため、名前だけに落とす。
|
|
69
|
+
const toolNames = entry?.tools && typeof entry.tools === "object" ? Object.keys(entry.tools) : []
|
|
70
|
+
|
|
71
|
+
let status
|
|
72
|
+
if (startupState === "ready") {
|
|
73
|
+
status = "connected"
|
|
74
|
+
} else if (startupState === "starting") {
|
|
75
|
+
status = "pending"
|
|
76
|
+
} else if (startupState === "failed" || startupState === "cancelled") {
|
|
77
|
+
status = isAuthFailure(startupError, authStatus) ? "needs-auth" : "failed"
|
|
78
|
+
} else if (authStatus === "notLoggedIn") {
|
|
79
|
+
// 通知が来ていない (thread 起動前 / 取りこぼし) が、未ログインは確実に再認証が要る。
|
|
80
|
+
status = "needs-auth"
|
|
81
|
+
} else if (entry?.serverInfo) {
|
|
82
|
+
// 通知を取りこぼしても serverInfo が埋まっていれば handshake は成立している。
|
|
83
|
+
status = "connected"
|
|
84
|
+
} else {
|
|
85
|
+
status = "pending"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
status,
|
|
91
|
+
...(entry?.serverInfo ? { serverInfo: entry.serverInfo } : {}),
|
|
92
|
+
...(startupError ? { error: truncateError(startupError) } : {}),
|
|
93
|
+
tools: toolNames.map((n) => ({ name: n })),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 起動失敗が「認証が要る」由来か。OAuth フロー (再認証ボタン) に倒してよいかの判定。 */
|
|
98
|
+
function isAuthFailure(error, authStatus) {
|
|
99
|
+
if (authStatus === "notLoggedIn") return true
|
|
100
|
+
if (!error) return false
|
|
101
|
+
// 実測のエラー文言:
|
|
102
|
+
// "MCP client for `hub` failed to start: ... Auth error: OAuth authorization required,
|
|
103
|
+
// when send initialize request"
|
|
104
|
+
return /oauth|authorization required|unauthorized|401/i.test(error)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** エラー文言は UI の 1 行に収まらないため要約する (全文は agent のログに残る)。 */
|
|
108
|
+
function truncateError(error) {
|
|
109
|
+
const s = String(error)
|
|
110
|
+
if (/oauth|authorization required/i.test(s)) return "OAuth 認証が必要です"
|
|
111
|
+
return s.length > 160 ? `${s.slice(0, 157)}…` : s
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class CodexMcpControl {
|
|
115
|
+
/**
|
|
116
|
+
* @param {{logger?: object, codexCmd?: string, cwd?: string, idleMs?: number,
|
|
117
|
+
* clientFactory?: (opts: object) => CodexAppServerClient}} [opts]
|
|
118
|
+
*/
|
|
119
|
+
constructor({ logger, codexCmd, cwd, idleMs, clientFactory } = {}) {
|
|
120
|
+
this.logger = logger
|
|
121
|
+
this.codexCmd = codexCmd
|
|
122
|
+
// control は特定のプロジェクトに属さない。MCP 設定はグローバル (~/.codex/config.toml)
|
|
123
|
+
// なので home を cwd にする (プロジェクト固有の設定に引きずられないため)。
|
|
124
|
+
this.cwd = cwd || os.homedir()
|
|
125
|
+
this.idleMs = typeof idleMs === "number" ? idleMs : IDLE_MS
|
|
126
|
+
this._clientFactory =
|
|
127
|
+
clientFactory || ((o) => new CodexAppServerClient(o))
|
|
128
|
+
|
|
129
|
+
this.client = null
|
|
130
|
+
/** @type {Map<string, {status: string, error: string|null}>} name → 起動状態。 */
|
|
131
|
+
this.startup = new Map()
|
|
132
|
+
this._starting = null
|
|
133
|
+
this._idleTimer = null
|
|
134
|
+
/** OAuth 完了通知の待受 (name → resolve)。 */
|
|
135
|
+
this._oauthWaiters = new Map()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** control クライアントを起動し、捨てスレッドで MCP 起動を誘発して通知を集める。 */
|
|
139
|
+
async _ensure() {
|
|
140
|
+
if (this.client && !this.client.closed) return this.client
|
|
141
|
+
if (this._starting) return this._starting
|
|
142
|
+
|
|
143
|
+
this._starting = (async () => {
|
|
144
|
+
const client = this._clientFactory({
|
|
145
|
+
cwd: this.cwd,
|
|
146
|
+
codexCmd: this.codexCmd,
|
|
147
|
+
logger: this.logger,
|
|
148
|
+
})
|
|
149
|
+
client.on("notification", (msg) => this._onNotification(msg))
|
|
150
|
+
client.on("request", (msg) => {
|
|
151
|
+
// control では承認要求は起こり得ない (turn を開始しないため) が、万一来ても
|
|
152
|
+
// ハングさせないよう即エラーを返す。
|
|
153
|
+
client.respondError(msg.id, {
|
|
154
|
+
code: -32601,
|
|
155
|
+
message: "codex mcp control does not handle server requests",
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
client.on("exit", () => {
|
|
159
|
+
this.client = null
|
|
160
|
+
this.startup.clear()
|
|
161
|
+
})
|
|
162
|
+
client.start()
|
|
163
|
+
|
|
164
|
+
await client.request("initialize", {
|
|
165
|
+
clientInfo: { name: "hub-agent", title: "Hub Agent", version: "mcp-control" },
|
|
166
|
+
})
|
|
167
|
+
client.notify("initialized", {})
|
|
168
|
+
|
|
169
|
+
// ⚠️ thread を張らないと mcpServer/startupStatus/updated が一切飛ばない
|
|
170
|
+
// (2026-07-14 実機検証)。turn は開始しないのでトークン消費は無い。
|
|
171
|
+
await client.request("thread/start", { cwd: this.cwd })
|
|
172
|
+
|
|
173
|
+
this.client = client
|
|
174
|
+
return client
|
|
175
|
+
})()
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return await this._starting
|
|
179
|
+
} finally {
|
|
180
|
+
this._starting = null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_onNotification(msg) {
|
|
185
|
+
if (msg?.method === "mcpServer/startupStatus/updated") {
|
|
186
|
+
const p = msg.params || {}
|
|
187
|
+
if (p.name) {
|
|
188
|
+
this.startup.set(p.name, { status: p.status, error: p.error ?? null })
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (msg?.method === "mcpServer/oauthLogin/completed") {
|
|
193
|
+
const p = msg.params || {}
|
|
194
|
+
const waiter = this._oauthWaiters.get(p.name)
|
|
195
|
+
if (waiter) {
|
|
196
|
+
this._oauthWaiters.delete(p.name)
|
|
197
|
+
waiter({ success: p.success === true, error: p.error ?? null })
|
|
198
|
+
}
|
|
199
|
+
this.logger?.info?.(
|
|
200
|
+
{ server: p.name, success: p.success },
|
|
201
|
+
"codex mcp oauth login completed",
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 起動通知が出揃うまで待つ。全サーバーが終端状態 (ready/failed/cancelled) になり、
|
|
208
|
+
* かつ静穏時間 (新規通知が来ない) を満たしたら抜ける。上限で打ち切る。
|
|
209
|
+
*/
|
|
210
|
+
async _waitForStartupSettle({ quietMs = SETTLE_QUIET_MS, maxMs = SETTLE_MAX_MS } = {}) {
|
|
211
|
+
const deadline = Date.now() + maxMs
|
|
212
|
+
let lastSize = -1
|
|
213
|
+
let lastChange = Date.now()
|
|
214
|
+
for (;;) {
|
|
215
|
+
const terminal = [...this.startup.values()].every(
|
|
216
|
+
(s) => s.status === "ready" || s.status === "failed" || s.status === "cancelled",
|
|
217
|
+
)
|
|
218
|
+
const size = this.startup.size
|
|
219
|
+
if (size !== lastSize) {
|
|
220
|
+
lastSize = size
|
|
221
|
+
lastChange = Date.now()
|
|
222
|
+
}
|
|
223
|
+
if (size > 0 && terminal && Date.now() - lastChange >= quietMs) return
|
|
224
|
+
if (Date.now() >= deadline) return
|
|
225
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** アイドルタイマーを引き直す (触られなくなって idleMs 経過したら破棄)。 */
|
|
230
|
+
_touch() {
|
|
231
|
+
if (this._idleTimer) clearTimeout(this._idleTimer)
|
|
232
|
+
if (this.idleMs <= 0) return
|
|
233
|
+
this._idleTimer = setTimeout(() => this.stop(), this.idleMs)
|
|
234
|
+
this._idleTimer.unref?.()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* MCP サーバー一覧を Cockpit 用に正規化して返す。
|
|
239
|
+
* @param {{reload?: boolean}} [opts] reload=true で control を作り直し設定を読み直す。
|
|
240
|
+
*/
|
|
241
|
+
async status({ reload = false } = {}) {
|
|
242
|
+
if (reload) this.stop()
|
|
243
|
+
const client = await this._ensure()
|
|
244
|
+
await this._waitForStartupSettle()
|
|
245
|
+
|
|
246
|
+
const res = await client.request("mcpServerStatus/list", {
|
|
247
|
+
// full だと resources / resourceTemplates まで載って更に膨らむ。
|
|
248
|
+
detail: "toolsAndAuthOnly",
|
|
249
|
+
})
|
|
250
|
+
const rows = res?.data ?? []
|
|
251
|
+
this._touch()
|
|
252
|
+
return rows.map((entry) => normalizeCodexMcpServer(entry, this.startup.get(entry?.name)))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 設定を読み直して MCP を張り直す。
|
|
257
|
+
*
|
|
258
|
+
* Codex には Claude の `reconnectMcpServer(name)` に相当する「1 サーバーだけ再接続」が
|
|
259
|
+
* 無く、`config/mcpServer/reload` は設定全体の再読込 (params 無し) となる。個別指定が
|
|
260
|
+
* 効かない以上、control を作り直して全サーバーを張り直すのが最も確実なので、
|
|
261
|
+
* server_name は「どのサーバーの操作としてログに残すか」だけに使う。
|
|
262
|
+
*/
|
|
263
|
+
async reconnect(serverName) {
|
|
264
|
+
const client = await this._ensure()
|
|
265
|
+
try {
|
|
266
|
+
await client.request("config/mcpServer/reload", null)
|
|
267
|
+
} catch (err) {
|
|
268
|
+
// reload が無い / 失敗しても、control 作り直しで実質同じ効果が得られる。
|
|
269
|
+
this.logger?.warn?.(
|
|
270
|
+
{ err: err?.message, server: serverName },
|
|
271
|
+
"codex config/mcpServer/reload failed; falling back to control restart",
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
// 設定再読込後の状態を取り直す (control ごと作り直して startup 通知を採り直す)。
|
|
275
|
+
const servers = await this.status({ reload: true })
|
|
276
|
+
return { servers, targeted: 0 }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* OAuth 認証を開始し、認可 URL を返す。
|
|
281
|
+
*
|
|
282
|
+
* codex がホスト上のループバックでコールバックを待ち受けるため、Cockpit 側での
|
|
283
|
+
* コールバック URL 貼り付けは不要 (callbackExpected: false)。
|
|
284
|
+
*/
|
|
285
|
+
async authenticate(serverName) {
|
|
286
|
+
const client = await this._ensure()
|
|
287
|
+
const res = await client.request("mcpServer/oauth/login", { name: serverName })
|
|
288
|
+
this._touch()
|
|
289
|
+
return {
|
|
290
|
+
authorizationUrl: res?.authorizationUrl,
|
|
291
|
+
// Claude の localhost コールバック型と違い、貼り付け UI は出さない。
|
|
292
|
+
callbackExpected: false,
|
|
293
|
+
requiresUserAction: true,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** control クライアントを停止する (アイドル破棄 / reload 用)。二重呼び出しは無害。 */
|
|
298
|
+
stop() {
|
|
299
|
+
if (this._idleTimer) {
|
|
300
|
+
clearTimeout(this._idleTimer)
|
|
301
|
+
this._idleTimer = null
|
|
302
|
+
}
|
|
303
|
+
if (this.client) {
|
|
304
|
+
try {
|
|
305
|
+
this.client.stop()
|
|
306
|
+
} catch {
|
|
307
|
+
// 既に落ちている場合等は無視して良い (stop の冪等性を優先)。
|
|
308
|
+
}
|
|
309
|
+
this.client = null
|
|
310
|
+
}
|
|
311
|
+
this.startup.clear()
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/hub-bundle.mjs
CHANGED
|
@@ -300,6 +300,15 @@ function runOnce(argv, { logger } = {}) {
|
|
|
300
300
|
* (Cockpit 用途では install.sh が事前に claude をインストールしている
|
|
301
301
|
* 想定だが、独自運用環境への配慮で fail-soft にする)
|
|
302
302
|
*/
|
|
303
|
+
/**
|
|
304
|
+
* ホスト資格ヘッダ(`X-Cockpit-Agent`)を付与する Hub の MCP サーバー名。
|
|
305
|
+
*
|
|
306
|
+
* Hub backend の acting tenant はこのヘッダで**ホストのテナントに固定**される。両方に
|
|
307
|
+
* 付けるのが正(`hub` だけだと、社内が実際に使う `hub_staff` が固定されず、フロントの
|
|
308
|
+
* ワークスペース切替で実行中セッションがデータを読めなくなる)。
|
|
309
|
+
*/
|
|
310
|
+
export const HUB_MCP_NAMES = new Set(["hub", "hub_staff"])
|
|
311
|
+
|
|
303
312
|
export async function applyMcpServers(bundle, { agentId, agentToken, logger } = {}) {
|
|
304
313
|
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
305
314
|
if (servers.length === 0) return { added: [], skipped: [] }
|
|
@@ -321,12 +330,21 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
|
|
|
321
330
|
const transport = s.transport || "http"
|
|
322
331
|
const scope = s.scope || "user"
|
|
323
332
|
|
|
324
|
-
// 越境制御 (2026-07):
|
|
325
|
-
// `X-Cockpit-Agent: <agentId>:<agentToken>` をヘッダで付与する。Hub
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
333
|
+
// 越境制御 (2026-07-14 訂正): **Hub の MCP(`hub` / `hub_staff`)の両方**に、この
|
|
334
|
+
// ホストの資格 `X-Cockpit-Agent: <agentId>:<agentToken>` をヘッダで付与する。Hub
|
|
335
|
+
// backend はこれを検証してホストのテナントを解決し、acting tenant をそのテナントに
|
|
336
|
+
// **固定**する(フロントエンドのワークスペース切替より優先。端末とワークスペースを
|
|
337
|
+
// 紐づけるのが設計意図)。外部 MCP には付けない。
|
|
338
|
+
//
|
|
339
|
+
// ⚠️ かつて `hub` にしか付けていなかったが、**設計意図が未達だった**。社内の横断作業は
|
|
340
|
+
// decision B により `hub_staff` を使うため、**実際に使う経路が固定されず**、フロントを
|
|
341
|
+
// 個人WSに切り替えると実行中の Cockpit セッションがココログラフのデータを読めなくなる
|
|
342
|
+
// 回帰が残っていた(本番実測 2026-07-14)。
|
|
343
|
+
//
|
|
344
|
+
// `hub_staff` を固定しても社内スタッフの横断は死なない: 提供元(ココログラフ)ホストは
|
|
345
|
+
// ココログラフテナントに固定され、backend の `is_unrestricted` はそこで真になるため
|
|
346
|
+
// (固定 ≠ 横断不可。固定されたテナントが提供元かどうかで決まる)。
|
|
347
|
+
const wantHeader = HUB_MCP_NAMES.has(s.name) && agentId && agentToken
|
|
330
348
|
|
|
331
349
|
// ⚠️ 実 CLI で確認した 2 つの罠(claude-code、モックでは絶対に出ない):
|
|
332
350
|
//
|
|
@@ -357,8 +375,8 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
|
|
|
357
375
|
await runOnce(argv, { logger })
|
|
358
376
|
added.push(s.name)
|
|
359
377
|
} catch (err) {
|
|
360
|
-
//
|
|
361
|
-
//
|
|
378
|
+
// ヘッダ不要の外部 MCP は既存なら "already exists" で失敗する。これは「既に正しく
|
|
379
|
+
// 登録済み」なので実害なし。ログだけ残して継続する。
|
|
362
380
|
logger?.warn({ name: s.name, err: err.message }, "claude mcp add failed")
|
|
363
381
|
skipped.push(s.name)
|
|
364
382
|
}
|
package/src/main.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs
|
|
|
25
25
|
import { WsClient } from "./ws-client.mjs"
|
|
26
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
27
27
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
28
|
+
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
28
29
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
29
30
|
import { UploadManager } from "./claude-upload.mjs"
|
|
30
31
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
@@ -541,6 +542,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
541
542
|
? new CodexStreamBridge({ logger })
|
|
542
543
|
: null
|
|
543
544
|
|
|
545
|
+
// Codex の MCP 状態 control (agent-global, セッション非依存)。MCP は ~/.codex/config.toml
|
|
546
|
+
// = アカウント単位の設定で全セッション共有のため、claudeBridge の「MCP control 専用
|
|
547
|
+
// query」と同じ流儀で、チャットの thread に紐付けない専用クライアントで扱う。
|
|
548
|
+
// codex バイナリが無ければ null (codex.mcp.* は cli_unavailable を返す)。
|
|
549
|
+
const codexMcpControl = availableClis.includes("codex")
|
|
550
|
+
? new CodexMcpControl({ logger })
|
|
551
|
+
: null
|
|
552
|
+
|
|
544
553
|
// Cockpit チャットモードの添付ファイル受信器 (browser → agent のチャンク送信を
|
|
545
554
|
// ローカル FS に保存し、保存パスを返す)。SDK 有無に関わらず生成してよいが、添付は
|
|
546
555
|
// チャットモード専用機能なので claudeBridge と同じく stream モード前提で使う。
|
|
@@ -820,7 +829,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
820
829
|
// permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
|
|
821
830
|
if (isFastPathMessage(msg?.type)) {
|
|
822
831
|
Promise.resolve(
|
|
823
|
-
dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }),
|
|
832
|
+
dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
|
|
824
833
|
).catch((err) => {
|
|
825
834
|
logger.error(
|
|
826
835
|
{ err: err.message, type: msg?.type },
|
|
@@ -830,7 +839,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
830
839
|
return
|
|
831
840
|
}
|
|
832
841
|
dispatchChain = dispatchChain
|
|
833
|
-
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }))
|
|
842
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }))
|
|
834
843
|
.catch((err) => {
|
|
835
844
|
logger.error(
|
|
836
845
|
{ err: err.message, type: msg?.type },
|
|
@@ -1121,7 +1130,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1121
1130
|
)
|
|
1122
1131
|
})
|
|
1123
1132
|
|
|
1124
|
-
return { client, plugins, ptyBridge, claudeBridge, codexBridge, uploadManager }
|
|
1133
|
+
return { client, plugins, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }
|
|
1125
1134
|
}
|
|
1126
1135
|
|
|
1127
1136
|
const SESSION_EVENTS_DIR =
|
|
@@ -3833,6 +3842,71 @@ async function dispatch(msg, ctx) {
|
|
|
3833
3842
|
}
|
|
3834
3843
|
return
|
|
3835
3844
|
}
|
|
3845
|
+
case "codex.mcp.status":
|
|
3846
|
+
case "codex.mcp.reconnect":
|
|
3847
|
+
case "codex.mcp.authenticate": {
|
|
3848
|
+
// Codex 版 MCP control (agent-global, セッション非依存) — 2026-07-14。
|
|
3849
|
+
// claude.mcp.* と同じ request_id 相関・同じ応答形 (servers[] は Cockpit の
|
|
3850
|
+
// McpServerStatus に正規化済み) にすることで、フロントの Popover / 再認証 UI を
|
|
3851
|
+
// そのまま再利用する。
|
|
3852
|
+
//
|
|
3853
|
+
// Claude との差分 (Codex 側に対応物が無いもの):
|
|
3854
|
+
// - callback : codex がホスト上のループバックでコールバックを受けるため不要
|
|
3855
|
+
// (authenticate の応答で callbackExpected: false を返す)
|
|
3856
|
+
// - clearauth: 対応する RPC が無い (CLI の `codex mcp logout` のみ)
|
|
3857
|
+
const request_id = msg.request_id
|
|
3858
|
+
if (!ctx.codexMcpControl) {
|
|
3859
|
+
ctx.client.send({
|
|
3860
|
+
type: "codex.mcp.response",
|
|
3861
|
+
request_id,
|
|
3862
|
+
action: msg.type,
|
|
3863
|
+
error: "codex_cli_unavailable",
|
|
3864
|
+
})
|
|
3865
|
+
return
|
|
3866
|
+
}
|
|
3867
|
+
try {
|
|
3868
|
+
let result
|
|
3869
|
+
switch (msg.type) {
|
|
3870
|
+
case "codex.mcp.status":
|
|
3871
|
+
result = {
|
|
3872
|
+
servers: await ctx.codexMcpControl.status({
|
|
3873
|
+
reload: msg.reload === true,
|
|
3874
|
+
}),
|
|
3875
|
+
}
|
|
3876
|
+
break
|
|
3877
|
+
case "codex.mcp.reconnect": {
|
|
3878
|
+
const r = await ctx.codexMcpControl.reconnect(msg.server_name)
|
|
3879
|
+
result = { servers: r.servers, targeted: r.targeted }
|
|
3880
|
+
break
|
|
3881
|
+
}
|
|
3882
|
+
case "codex.mcp.authenticate":
|
|
3883
|
+
result = {
|
|
3884
|
+
auth: await ctx.codexMcpControl.authenticate(msg.server_name),
|
|
3885
|
+
}
|
|
3886
|
+
break
|
|
3887
|
+
}
|
|
3888
|
+
ctx.client.send({
|
|
3889
|
+
type: "codex.mcp.response",
|
|
3890
|
+
request_id,
|
|
3891
|
+
action: msg.type,
|
|
3892
|
+
server_name: msg.server_name,
|
|
3893
|
+
...result,
|
|
3894
|
+
})
|
|
3895
|
+
} catch (err) {
|
|
3896
|
+
ctx.logger?.warn(
|
|
3897
|
+
{ err: err?.message, action: msg.type, server: msg.server_name },
|
|
3898
|
+
"codex.mcp control request failed",
|
|
3899
|
+
)
|
|
3900
|
+
ctx.client.send({
|
|
3901
|
+
type: "codex.mcp.response",
|
|
3902
|
+
request_id,
|
|
3903
|
+
action: msg.type,
|
|
3904
|
+
server_name: msg.server_name,
|
|
3905
|
+
error: err?.message || "mcp_control_failed",
|
|
3906
|
+
})
|
|
3907
|
+
}
|
|
3908
|
+
return
|
|
3909
|
+
}
|
|
3836
3910
|
case "claude.queue.cancel":
|
|
3837
3911
|
// 送信待ち (pending) メッセージを id 指定で取り消す (0.6.26)。
|
|
3838
3912
|
if (!ctx.claudeBridge) return
|