@cocorograph/hub-agent 0.7.58 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.58",
3
+ "version": "0.7.59",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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/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