@cocorograph/hub-agent 0.7.58 → 0.7.60
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/codex-stream-bridge.mjs +54 -2
- package/src/main.mjs +85 -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
|
+
}
|
|
@@ -77,6 +77,41 @@ function flattenThreadItems(thread) {
|
|
|
77
77
|
return items
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* resume 応答から Codex の「権威的なターン進行状態」を取り出す。
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ Codex は Claude と違い、ターンが走っているかを **明示フィールド** で持つ (公式
|
|
84
|
+
* app-server スキーマで確認):
|
|
85
|
+
* - Thread.status.type = notLoaded | idle | systemError | active
|
|
86
|
+
* - Turn.status = completed | interrupted | failed | inProgress
|
|
87
|
+
* そのため「会話履歴の末尾が user か assistant か」からの推測 (Claude 由来の
|
|
88
|
+
* deriveTurnActiveFromHistory) は不要かつ有害 (ツール実行のみで終わったターンや中断
|
|
89
|
+
* ターンで末尾が user メッセージになり、生成中スピナーが永久固着する既知バグの原因)。
|
|
90
|
+
*
|
|
91
|
+
* resume 時は app-server が新プロセスで、実際に走っているターンは無いため Codex は
|
|
92
|
+
* 通常 `idle` を返す。ゆえに `threadStatus === "active"` を唯一の権威 turn-active
|
|
93
|
+
* シグナルとして採用し、フロントの hydrate 時 turnActive をこれで決めさせる。
|
|
94
|
+
*
|
|
95
|
+
* @param {object|undefined} thread thread/resume の result.thread
|
|
96
|
+
* @returns {{threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean, activeTurnId: string|null}}
|
|
97
|
+
*/
|
|
98
|
+
export function deriveResumeTurnState(thread) {
|
|
99
|
+
const threadStatus = typeof thread?.status?.type === "string" ? thread.status.type : null
|
|
100
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
101
|
+
const lastTurn = turns.length ? turns[turns.length - 1] : null
|
|
102
|
+
const lastTurnStatus = typeof lastTurn?.status === "string" ? lastTurn.status : null
|
|
103
|
+
// 権威シグナル: スレッドが active のときだけ「ターン実行中」。fresh resume は idle。
|
|
104
|
+
const turnActive = threadStatus === "active"
|
|
105
|
+
// active かつ最後のターンが inProgress のときのみ activeTurnId を復元する
|
|
106
|
+
// (input() が turn/steer へ正しく分岐できるように)。それ以外は null のまま
|
|
107
|
+
// = 次の入力は turn/start になり、固着しない。
|
|
108
|
+
const activeTurnId =
|
|
109
|
+
turnActive && lastTurnStatus === "inProgress" && typeof lastTurn?.id === "string"
|
|
110
|
+
? lastTurn.id
|
|
111
|
+
: null
|
|
112
|
+
return { threadStatus, lastTurnStatus, turnActive, activeTurnId }
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
/**
|
|
81
116
|
* 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
|
|
82
117
|
* `reason` へ分類する。
|
|
@@ -134,7 +169,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
134
169
|
}
|
|
135
170
|
|
|
136
171
|
/**
|
|
137
|
-
* @returns {Promise<{resuming: boolean, threadId: string, history: object[]
|
|
172
|
+
* @returns {Promise<{resuming: boolean, threadId: string, history: object[],
|
|
173
|
+
* threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
|
|
138
174
|
*/
|
|
139
175
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
140
176
|
if (this.sessions.has(stream_id)) {
|
|
@@ -173,7 +209,23 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
173
209
|
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
174
210
|
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
175
211
|
const history = resuming ? flattenThreadItems(result?.thread) : []
|
|
176
|
-
|
|
212
|
+
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
213
|
+
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
214
|
+
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
215
|
+
const turnState = resuming
|
|
216
|
+
? deriveResumeTurnState(result?.thread)
|
|
217
|
+
: { threadStatus: "idle", lastTurnStatus: null, turnActive: false, activeTurnId: null }
|
|
218
|
+
// Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
|
|
219
|
+
// (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
|
|
220
|
+
session.activeTurnId = turnState.activeTurnId
|
|
221
|
+
return {
|
|
222
|
+
resuming,
|
|
223
|
+
threadId: session.threadId,
|
|
224
|
+
history,
|
|
225
|
+
threadStatus: turnState.threadStatus,
|
|
226
|
+
lastTurnStatus: turnState.lastTurnStatus,
|
|
227
|
+
turnActive: turnState.turnActive,
|
|
228
|
+
}
|
|
177
229
|
} catch (err) {
|
|
178
230
|
this.sessions.delete(stream_id)
|
|
179
231
|
client.stop()
|
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
|
|
@@ -3878,6 +3952,14 @@ async function dispatch(msg, ctx) {
|
|
|
3878
3952
|
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
3879
3953
|
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3880
3954
|
history: info.history || [],
|
|
3955
|
+
// 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
|
|
3956
|
+
// FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
|
|
3957
|
+
// resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、
|
|
3958
|
+
// 生成中スピナー固着 (中断/ツールのみ終了ターンで末尾が user メッセージになる
|
|
3959
|
+
// ケース) が根治する。
|
|
3960
|
+
turn_active: info.turnActive === true,
|
|
3961
|
+
thread_status: info.threadStatus || null,
|
|
3962
|
+
last_turn_status: info.lastTurnStatus || null,
|
|
3881
3963
|
})
|
|
3882
3964
|
// S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
|
|
3883
3965
|
// 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
|