@cocorograph/hub-agent 0.8.45 → 0.8.47
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-appserver-client.mjs +12 -5
- package/src/codex-binary.mjs +90 -0
- package/src/codex-stream-bridge.mjs +4 -5
- package/src/config.mjs +10 -4
- package/src/hub-bundle.mjs +18 -3
- package/src/main.mjs +130 -61
- package/src/tmux.mjs +45 -12
- package/src/usage.mjs +169 -31
- package/src/ws-client.mjs +136 -37
package/package.json
CHANGED
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from "node:child_process"
|
|
19
19
|
import { EventEmitter } from "node:events"
|
|
20
|
-
|
|
21
|
-
const DEFAULT_CODEX_CMD = "codex"
|
|
20
|
+
import { requireCodexBinary } from "./codex-binary.mjs"
|
|
22
21
|
|
|
23
22
|
// S3 Codex 堅牢化 (2026-07-05): request() の既定タイムアウト。app-server プロセスが
|
|
24
23
|
// 生きたまま応答しなくなるケース (ハング/デッドロック) では、プロセス exit イベントに
|
|
@@ -35,15 +34,16 @@ function resolveRequestTimeoutMs(explicit) {
|
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export class CodexAppServerClient extends EventEmitter {
|
|
38
|
-
constructor({ cwd, codexCmd, env, logger, spawnFn, requestTimeoutMs } = {}) {
|
|
37
|
+
constructor({ cwd, codexCmd, env, logger, spawnFn, binaryResolver, requestTimeoutMs } = {}) {
|
|
39
38
|
super()
|
|
40
39
|
this.cwd = cwd
|
|
41
|
-
this.codexCmd = codexCmd
|
|
40
|
+
this.codexCmd = codexCmd
|
|
42
41
|
this.env = env
|
|
43
42
|
this.logger = logger
|
|
44
43
|
// テストから spawn をスタブ化できるよう注入可能にする (実 codex バイナリ不要で
|
|
45
44
|
// JSON-RPC 相関ロジックだけを検証するため。既定は node:child_process の spawn)。
|
|
46
45
|
this._spawnFn = spawnFn || spawn
|
|
46
|
+
this._binaryResolver = binaryResolver || requireCodexBinary
|
|
47
47
|
// テストから短縮できるよう注入可能にする (既定は 20000ms、env でも上書き可能)。
|
|
48
48
|
// 0 を指定するとタイムアウト無効 (旧挙動、テスト以外での使用は非推奨)。
|
|
49
49
|
this._requestTimeoutMs = resolveRequestTimeoutMs(requestTimeoutMs)
|
|
@@ -57,6 +57,13 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
57
57
|
/** codex app-server プロセスを起動する。二重呼び出しは無害 (no-op)。 */
|
|
58
58
|
start() {
|
|
59
59
|
if (this.child || this.closed) return
|
|
60
|
+
const resolved = this.codexCmd
|
|
61
|
+
? { available: true, path: this.codexCmd, source: "explicit" }
|
|
62
|
+
: this._binaryResolver()
|
|
63
|
+
this.logger?.info?.(
|
|
64
|
+
{ codex_binary_source: resolved.source, codex_binary_path: resolved.path },
|
|
65
|
+
"codex binary resolved for app-server",
|
|
66
|
+
)
|
|
60
67
|
// Cockpit Codex 両対応 Stream A5(2026-07-02 実機検証で確定、中村さん明示承認済み):
|
|
61
68
|
// app-server は stdio 駆動で TTY が無いため、Codex 標準の対話的 hook 信頼確認
|
|
62
69
|
// プロンプトを出す余地が無い。--dangerously-bypass-hook-trust を付けないと
|
|
@@ -102,7 +109,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
102
109
|
"app-server",
|
|
103
110
|
"--stdio",
|
|
104
111
|
]
|
|
105
|
-
this.child = this._spawnFn(
|
|
112
|
+
this.child = this._spawnFn(resolved.path, args, {
|
|
106
113
|
cwd: this.cwd,
|
|
107
114
|
env: this.env || process.env,
|
|
108
115
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { constants as fsConstants, accessSync, statSync } from "node:fs"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cockpit が起動する Codex の共通 resolver。
|
|
8
|
+
*
|
|
9
|
+
* 呼び出しごとに再評価し、plugin の更新・一時不在からの復帰を daemon 再起動なしで
|
|
10
|
+
* 反映する。HUB_CODEX_BIN は明示指定なので、不正時に別バイナリへ黙って切り替えない。
|
|
11
|
+
*/
|
|
12
|
+
export function resolveCodexBinary({
|
|
13
|
+
env = process.env,
|
|
14
|
+
homedir = os.homedir(),
|
|
15
|
+
platform = process.platform,
|
|
16
|
+
accessSyncImpl = accessSync,
|
|
17
|
+
statSyncImpl = statSync,
|
|
18
|
+
execFileSyncImpl = execFileSync,
|
|
19
|
+
} = {}) {
|
|
20
|
+
const pathApi = platform === "win32" ? path.win32 : path
|
|
21
|
+
const executable = (candidate) => {
|
|
22
|
+
try {
|
|
23
|
+
if (!statSyncImpl(candidate).isFile()) return false
|
|
24
|
+
accessSyncImpl(candidate, fsConstants.X_OK)
|
|
25
|
+
return true
|
|
26
|
+
} catch {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const explicit = typeof env.HUB_CODEX_BIN === "string" ? env.HUB_CODEX_BIN.trim() : ""
|
|
32
|
+
if (explicit) {
|
|
33
|
+
if (!pathApi.isAbsolute(explicit)) {
|
|
34
|
+
return { available: false, path: explicit, source: "env", error: `HUB_CODEX_BIN must be an absolute path: ${explicit}` }
|
|
35
|
+
}
|
|
36
|
+
const explicitPath = explicit
|
|
37
|
+
if (executable(explicitPath)) {
|
|
38
|
+
return { available: true, path: explicitPath, source: "env" }
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
available: false,
|
|
42
|
+
path: explicitPath,
|
|
43
|
+
source: "env",
|
|
44
|
+
error: `HUB_CODEX_BIN is not executable: ${explicitPath}`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pluginPath = pathApi.join(homedir, ".codex", "plugins", ".plugin-appserver", "codex")
|
|
49
|
+
if (executable(pluginPath)) {
|
|
50
|
+
return { available: true, path: pluginPath, source: "plugin-appserver" }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const lookup = platform === "win32" ? "where" : "which"
|
|
55
|
+
const output = execFileSyncImpl(lookup, ["codex"], { encoding: "utf8" })
|
|
56
|
+
const resolved = String(output)
|
|
57
|
+
.split(/\r?\n/)
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.find(Boolean)
|
|
60
|
+
if (resolved) {
|
|
61
|
+
return {
|
|
62
|
+
available: true,
|
|
63
|
+
path: pathApi.isAbsolute(resolved) ? resolved : pathApi.resolve(resolved),
|
|
64
|
+
source: "path",
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// PATH に無い場合は unavailable。plugin は次回呼び出し時に再評価される。
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { available: false, path: null, source: "path", error: "codex CLI is unavailable" }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function requireCodexBinary(opts) {
|
|
75
|
+
const resolved = resolveCodexBinary(opts)
|
|
76
|
+
if (!resolved.available) {
|
|
77
|
+
const err = new Error(resolved.error || "codex CLI is unavailable")
|
|
78
|
+
err.code = "CODEX_CLI_UNAVAILABLE"
|
|
79
|
+
err.codexBinary = resolved
|
|
80
|
+
throw err
|
|
81
|
+
}
|
|
82
|
+
return resolved
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** POSIX shell の単一 argv として安全に埋め込む。 */
|
|
86
|
+
export function quotePosixShellArg(value) {
|
|
87
|
+
const s = String(value)
|
|
88
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s) && !s.startsWith("-")) return s
|
|
89
|
+
return `'${s.replaceAll("'", `'"'"'`)}'`
|
|
90
|
+
}
|
|
@@ -814,16 +814,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
814
814
|
})
|
|
815
815
|
this.sessions.set(stream_id, session)
|
|
816
816
|
this._wireClient(session)
|
|
817
|
-
client.start()
|
|
818
|
-
// app-server の spawn は同期呼び出し (fork/exec のみ)。プロセス起動の実コストは
|
|
819
|
-
// 直後の initialize RPC 応答待ちに現れるので、spawn_ms と initialize_ms は必ずセットで読む。
|
|
820
|
-
timing.spawn_ms = lap()
|
|
821
|
-
|
|
822
817
|
const resuming = !!resumeThreadId
|
|
823
818
|
// #3: initialize と thread op を分離し、thread op に到達したかを記録する。initialize / config
|
|
824
819
|
// / MCP 起動の失敗 (thread op 未到達) は attach 失敗分類の対象外にする (pointer を消さない)。
|
|
825
820
|
let reachedThreadOp = false
|
|
826
821
|
try {
|
|
822
|
+
client.start()
|
|
823
|
+
// app-server の spawn は同期呼び出し (fork/exec のみ)。プロセス起動の実コストは
|
|
824
|
+
// 直後の initialize RPC 応答待ちに現れるので、spawn_ms と initialize_ms は必ずセットで読む。
|
|
825
|
+
timing.spawn_ms = lap()
|
|
827
826
|
this.logger?.info?.(
|
|
828
827
|
{ stream_id, cwd, resuming, lifecycle_stage: "initialize_started" },
|
|
829
828
|
"codex app-server session initialization",
|
package/src/config.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import os from "node:os"
|
|
|
13
13
|
import { randomUUID } from "node:crypto"
|
|
14
14
|
import { execFile, execFileSync } from "node:child_process"
|
|
15
15
|
import { promisify } from "node:util"
|
|
16
|
+
import { resolveCodexBinary } from "./codex-binary.mjs"
|
|
16
17
|
|
|
17
18
|
const _execFileP = promisify(execFile)
|
|
18
19
|
|
|
@@ -62,12 +63,13 @@ export function detectPlatform({ execImpl } = {}) {
|
|
|
62
63
|
export const CLI_CANDIDATES = ["claude", "codex"]
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
*
|
|
66
|
+
* 起動可能な CLI 種別 (capability) を検出する。Cockpit が「この hub-agent で
|
|
66
67
|
* どの CLI を起動できるか」を知るため、起動時に 1 回呼んで enroll/hello payload で
|
|
67
|
-
* Hub に広告する (`CockpitAgent.available_clis`)。
|
|
68
|
+
* Hub に広告する (`CockpitAgent.available_clis`)。Codex は共通 resolver、その他は
|
|
69
|
+
* which/where が成功したものだけ返す。
|
|
68
70
|
*
|
|
69
|
-
* @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any
|
|
70
|
-
* exec
|
|
71
|
+
* @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any>, _resolveCodexBinary?:()=>{available:boolean}}} [opts]
|
|
72
|
+
* exec / _resolveCodexBinary はテスト差し替え用。
|
|
71
73
|
* @returns {Promise<string[]>} 例: ["claude", "codex"]
|
|
72
74
|
*/
|
|
73
75
|
export async function detectAvailableClis(opts = {}) {
|
|
@@ -76,6 +78,10 @@ export async function detectAvailableClis(opts = {}) {
|
|
|
76
78
|
const lookup = process.platform === "win32" ? "where" : "which"
|
|
77
79
|
const found = []
|
|
78
80
|
for (const cli of candidates) {
|
|
81
|
+
if (cli === "codex") {
|
|
82
|
+
if ((opts._resolveCodexBinary || resolveCodexBinary)().available) found.push(cli)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
79
85
|
try {
|
|
80
86
|
await exec(lookup, [cli])
|
|
81
87
|
found.push(cli)
|
package/src/hub-bundle.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import path from "node:path"
|
|
|
21
21
|
import os from "node:os"
|
|
22
22
|
import { spawn } from "node:child_process"
|
|
23
23
|
import { randomBytes, createHash } from "node:crypto"
|
|
24
|
+
import { resolveCodexBinary } from "./codex-binary.mjs"
|
|
24
25
|
|
|
25
26
|
const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
|
|
26
27
|
const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
|
|
@@ -479,7 +480,21 @@ export async function applyCodexMcpServers(
|
|
|
479
480
|
}
|
|
480
481
|
// CLI バイナリ解決 / 実行はテスト注入可能(既定は実 which/spawn)。実 subprocess に依存しない
|
|
481
482
|
// 決定的テストのため(環境の TMPDIR noexec / PATH 解決差で偽バイナリ方式が不安定になる問題の対処)。
|
|
482
|
-
const
|
|
483
|
+
const resolveCodexBin = resolveBinImpl || (async () => {
|
|
484
|
+
const resolved = resolveCodexBinary()
|
|
485
|
+
if (!resolved.available) {
|
|
486
|
+
logger?.warn?.(
|
|
487
|
+
{ source: resolved.source, path: resolved.path, err: resolved.error },
|
|
488
|
+
"codex CLI unavailable",
|
|
489
|
+
)
|
|
490
|
+
return null
|
|
491
|
+
}
|
|
492
|
+
logger?.info?.(
|
|
493
|
+
{ source: resolved.source, path: resolved.path },
|
|
494
|
+
"codex binary resolved for bundle sync",
|
|
495
|
+
)
|
|
496
|
+
return resolved.path
|
|
497
|
+
})
|
|
483
498
|
const run = runImpl || runOnce
|
|
484
499
|
|
|
485
500
|
const added = []
|
|
@@ -490,9 +505,9 @@ export async function applyCodexMcpServers(
|
|
|
490
505
|
// ユーザーの操作を勝手に奪う)。登録=対話(enroll / 明示手動 sync / 明示的認証)に限定し、自動同期は
|
|
491
506
|
// 「既に登録済み(= OAuth 認証済み)テーブルへ承認モードを補完する」ことだけを行う。
|
|
492
507
|
if (!nonInteractive) {
|
|
493
|
-
const codexBin = await
|
|
508
|
+
const codexBin = await resolveCodexBin("codex")
|
|
494
509
|
if (!codexBin) {
|
|
495
|
-
logger?.info("codex CLI
|
|
510
|
+
logger?.info("codex CLI が利用できないため MCP server 自動登録をスキップ")
|
|
496
511
|
skipped.push(...servers.map((s) => s?.name || "<unnamed>"))
|
|
497
512
|
} else {
|
|
498
513
|
for (const s of servers) {
|
package/src/main.mjs
CHANGED
|
@@ -50,6 +50,10 @@ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
|
50
50
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
51
51
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
52
52
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
53
|
+
import {
|
|
54
|
+
quotePosixShellArg,
|
|
55
|
+
resolveCodexBinary,
|
|
56
|
+
} from "./codex-binary.mjs"
|
|
53
57
|
import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
|
|
54
58
|
import { UploadManager } from "./claude-upload.mjs"
|
|
55
59
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
@@ -90,6 +94,7 @@ import {
|
|
|
90
94
|
} from "./claude-credentials.mjs"
|
|
91
95
|
import {
|
|
92
96
|
buildClaudeCmd,
|
|
97
|
+
buildCodexCmd,
|
|
93
98
|
buildWorktreeIndex,
|
|
94
99
|
createSession as createTmuxSession,
|
|
95
100
|
createWorktreeDir,
|
|
@@ -102,6 +107,8 @@ import {
|
|
|
102
107
|
paneClaudeAliveOrUnknown,
|
|
103
108
|
removeWorktree as removeWorktreeDir,
|
|
104
109
|
setTmuxGlobalEnv,
|
|
110
|
+
shouldLaunchPaneCli,
|
|
111
|
+
validateInitialPrompt,
|
|
105
112
|
} from "./tmux.mjs"
|
|
106
113
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
107
114
|
import { isSessionWarm } from "./live-sessions.mjs"
|
|
@@ -142,6 +149,21 @@ import {
|
|
|
142
149
|
} from "./proc-introspect.mjs"
|
|
143
150
|
import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
144
151
|
|
|
152
|
+
export function resolveCodexLoginCommand(configDir, { isDefault = false, _resolveCodexBinary = resolveCodexBinary } = {}) {
|
|
153
|
+
const resolved = _resolveCodexBinary()
|
|
154
|
+
if (!resolved.available) {
|
|
155
|
+
return { login_command: undefined, login_command_reason: resolved.error || "codex_cli_unavailable" }
|
|
156
|
+
}
|
|
157
|
+
const bin = quotePosixShellArg(resolved.path)
|
|
158
|
+
return {
|
|
159
|
+
login_command: isDefault ? `${bin} login` : `CODEX_HOME=${quotePosixShellArg(configDir)} ${bin} login`,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function codexAvailableForDispatch(ctx) {
|
|
164
|
+
return (ctx?._resolveCodexBinary || resolveCodexBinary)().available
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
const logger = pino({ name: "hub-agent" })
|
|
146
168
|
|
|
147
169
|
|
|
@@ -207,9 +229,12 @@ function codexDefaultsFromAgentConfig(config) {
|
|
|
207
229
|
*/
|
|
208
230
|
export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
209
231
|
const result = { killed: [], recreated: [], failed: [] }
|
|
232
|
+
const listSessionsForRestart = ctx._listTmuxSessions || listTmuxSessions
|
|
233
|
+
const killSessionsForRestart = ctx._killManySessions || killManySessions
|
|
234
|
+
const createSessionForRestart = ctx._createTmuxSession || createTmuxSession
|
|
210
235
|
let sessions = []
|
|
211
236
|
try {
|
|
212
|
-
sessions = await
|
|
237
|
+
sessions = await listSessionsForRestart({ plugins: ctx.plugins, logger: ctx.logger })
|
|
213
238
|
} catch (err) {
|
|
214
239
|
ctx.logger?.debug?.(
|
|
215
240
|
{ err: err?.message || String(err) },
|
|
@@ -218,24 +243,45 @@ export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
|
218
243
|
return result
|
|
219
244
|
}
|
|
220
245
|
if (!sessions.length) return result
|
|
221
|
-
const
|
|
222
|
-
|
|
246
|
+
const codexDefaults = codexDefaultsFromAgentConfig(ctx.config)
|
|
247
|
+
let prebuiltCodexCmd
|
|
248
|
+
const codexSessions = sessions.filter((s) => cliKindForSessionName(s.name) === "codex")
|
|
249
|
+
let sessionsToRestart = sessions
|
|
250
|
+
if (shouldLaunchPaneCli() && codexSessions.length) {
|
|
251
|
+
try {
|
|
252
|
+
prebuiltCodexCmd = buildCodexCmd({
|
|
253
|
+
...codexDefaults,
|
|
254
|
+
_resolveCodexBinary: ctx._resolveCodexBinary,
|
|
255
|
+
})
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const reason = err?.message || String(err)
|
|
258
|
+
result.failed.push(...codexSessions.map((s) => ({ name: s.name, reason })))
|
|
259
|
+
const blocked = new Set(codexSessions.map((s) => s.name))
|
|
260
|
+
sessionsToRestart = sessions.filter((s) => !blocked.has(s.name))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!sessionsToRestart.length) return result
|
|
264
|
+
const { killed } = await killSessionsForRestart(
|
|
265
|
+
sessionsToRestart.map((s) => s.name),
|
|
223
266
|
{ logger: ctx.logger },
|
|
224
267
|
)
|
|
225
268
|
result.killed = killed
|
|
226
|
-
for (const s of
|
|
269
|
+
for (const s of sessionsToRestart) {
|
|
227
270
|
if (!killed.includes(s.name)) continue
|
|
228
271
|
if (!s.cwd) {
|
|
229
272
|
result.failed.push({ name: s.name, reason: "cwd unknown" })
|
|
230
273
|
continue
|
|
231
274
|
}
|
|
232
275
|
try {
|
|
233
|
-
|
|
276
|
+
const cliKind = cliKindForSessionName(s.name)
|
|
277
|
+
await createSessionForRestart(s.name, s.cwd, {
|
|
234
278
|
// cliKind はセッション名 (`<dir>__codex` か否か) だけで決まる。渡し忘れると
|
|
235
279
|
// createSession が既定の claude として扱い、codex セッションの再生成でも
|
|
236
280
|
// AGENTS.md ではなく CLAUDE.md を同期対象にしてしまう。
|
|
237
|
-
cliKind
|
|
281
|
+
cliKind,
|
|
238
282
|
claudeCmd: claudeCmdFromAgentConfig(ctx.config),
|
|
283
|
+
codexCmd: cliKind === "codex" ? prebuiltCodexCmd : undefined,
|
|
284
|
+
...codexDefaults,
|
|
239
285
|
logger: ctx.logger,
|
|
240
286
|
})
|
|
241
287
|
result.recreated.push(s.name)
|
|
@@ -352,15 +398,14 @@ export function isFastPathMessage(type) {
|
|
|
352
398
|
* 捨てるため、WS 受信順に対する順序依存が無い。
|
|
353
399
|
*
|
|
354
400
|
* これを直列 dispatchChain から外す理由 (view/spawn 分離の核): 直列キューは「WS 受信順 = pane 反映順」
|
|
355
|
-
* を守るためのものだが、その代償として前方の重い処理 (codex.
|
|
356
|
-
*
|
|
401
|
+
* を守るためのものだが、その代償として前方の重い処理 (codex.attach の app-server spawn = 数秒、
|
|
402
|
+
* claude.sessions.request / codex.sessions.request の全ファイル読み) が後続の claude.attach を
|
|
357
403
|
* head-of-line blocking し、attach→ready を数十秒遅延させていた (本番計測 cold p95 40〜54s)。
|
|
358
|
-
* claude/codex が同一 dispatchChain
|
|
404
|
+
* claude/codex が同一 dispatchChain を共有するため一方の重い処理が他方の切替まで巻き込む。
|
|
359
405
|
* 読み取り RPC を並行レーンへ逃がすことで、タイムライン閲覧が live 接続 (spawn) の完了を待たなくなる。
|
|
360
406
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* single-flight ガードを併設して同時 spawn を 1 本に畳む (B で rollout disk 走査へ置換すれば spawn 自体が消える)。
|
|
407
|
+
* codex.sessions.request は rollout の disk 走査のみで app-server を spawn しない。
|
|
408
|
+
* CodexStreamBridge.listThreads 側の single-flight が同一走査の重複 I/O を畳む。
|
|
364
409
|
*/
|
|
365
410
|
export function isReadOnlyRpc(type) {
|
|
366
411
|
return (
|
|
@@ -758,11 +803,21 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
758
803
|
await kickSetupHubAiForDetectedClis({ logger })
|
|
759
804
|
|
|
760
805
|
// ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
|
|
761
|
-
// 加え、
|
|
762
|
-
//
|
|
763
|
-
//
|
|
806
|
+
// 加え、Codex capability の初期状態を広告する。実際の spawn は共通 resolver が
|
|
807
|
+
// 呼び出しごとに再評価するため、この初期値で bridge の生成可否は固定しない。
|
|
808
|
+
// available_clis の広告値自体は daemon 再起動まで更新されないが、spawn 可否は都度判定する。
|
|
764
809
|
const availableClis = await detectAvailableClis()
|
|
765
810
|
logger.info({ availableClis }, "available CLIs detected")
|
|
811
|
+
const initialCodexBinary = resolveCodexBinary()
|
|
812
|
+
logger.info(
|
|
813
|
+
{
|
|
814
|
+
available: initialCodexBinary.available,
|
|
815
|
+
source: initialCodexBinary.source,
|
|
816
|
+
path: initialCodexBinary.path,
|
|
817
|
+
error: initialCodexBinary.error,
|
|
818
|
+
},
|
|
819
|
+
"codex binary resolver initialized",
|
|
820
|
+
)
|
|
766
821
|
|
|
767
822
|
// Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
|
|
768
823
|
// テストでは引数で stub を差し込める。
|
|
@@ -771,20 +826,15 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
771
826
|
? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
|
|
772
827
|
: null
|
|
773
828
|
|
|
774
|
-
// codex app-server ベースの chat ブリッジ。
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
const codexBridge = availableClis.includes("codex")
|
|
778
|
-
? new CodexStreamBridge({ logger })
|
|
779
|
-
: null
|
|
829
|
+
// codex app-server ベースの chat ブリッジ。resolver は各 spawn 時に再評価するため、
|
|
830
|
+
// daemon 起動時に一時的に unavailable でも bridge 自体は保持する。
|
|
831
|
+
const codexBridge = new CodexStreamBridge({ logger })
|
|
780
832
|
|
|
781
833
|
// Codex の MCP 状態 control (agent-global, セッション非依存)。MCP は ~/.codex/config.toml
|
|
782
834
|
// = アカウント単位の設定で全セッション共有のため、claudeBridge の「MCP control 専用
|
|
783
835
|
// query」と同じ流儀で、チャットの thread に紐付けない専用クライアントで扱う。
|
|
784
|
-
//
|
|
785
|
-
const codexMcpControl =
|
|
786
|
-
? new CodexMcpControl({ logger })
|
|
787
|
-
: null
|
|
836
|
+
// resolver は control client の spawn 時にも再評価する。
|
|
837
|
+
const codexMcpControl = new CodexMcpControl({ logger })
|
|
788
838
|
|
|
789
839
|
// Cockpit チャットモードの添付ファイル受信器 (browser → agent のチャンク送信を
|
|
790
840
|
// ローカル FS に保存し、保存パスを返す)。SDK 有無に関わらず生成してよいが、添付は
|
|
@@ -3712,37 +3762,42 @@ export async function handleWorktreeCreate(msg, ctx) {
|
|
|
3712
3762
|
return
|
|
3713
3763
|
}
|
|
3714
3764
|
try {
|
|
3715
|
-
const
|
|
3716
|
-
|
|
3717
|
-
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
3718
|
-
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
3719
|
-
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
3720
|
-
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
3765
|
+
const worktreeCreate = ctx._createWorktreeDir || createWorktreeDir
|
|
3766
|
+
const tmuxCreate = ctx._createTmuxSession || createTmuxSession
|
|
3721
3767
|
const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
3722
|
-
const
|
|
3723
|
-
|
|
3768
|
+
const launchPaneCli = shouldLaunchPaneCli()
|
|
3769
|
+
const codexDefaults = codexDefaultsFromAgentConfig(ctx.config)
|
|
3770
|
+
const tmuxOptions = {
|
|
3724
3771
|
cliKind: wtCliKind,
|
|
3725
3772
|
claudeCmd:
|
|
3726
3773
|
typeof msg.claude_cmd === "string"
|
|
3727
3774
|
? msg.claude_cmd
|
|
3728
3775
|
: claudeCmdFromAgentConfig(ctx.config),
|
|
3729
3776
|
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
3730
|
-
|
|
3731
|
-
model:
|
|
3732
|
-
typeof msg.model === "string"
|
|
3733
|
-
? msg.model
|
|
3734
|
-
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
3777
|
+
model: typeof msg.model === "string" ? msg.model : codexDefaults.model,
|
|
3735
3778
|
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
3736
3779
|
approvalPolicy:
|
|
3737
3780
|
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
3738
|
-
effort:
|
|
3739
|
-
typeof msg.effort === "string"
|
|
3740
|
-
? msg.effort
|
|
3741
|
-
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
3781
|
+
effort: typeof msg.effort === "string" ? msg.effort : codexDefaults.effort,
|
|
3742
3782
|
initialPrompt:
|
|
3743
3783
|
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
3744
3784
|
logger: ctx.logger,
|
|
3745
|
-
|
|
3785
|
+
_resolveCodexBinary: ctx._resolveCodexBinary,
|
|
3786
|
+
}
|
|
3787
|
+
if (launchPaneCli) validateInitialPrompt(tmuxOptions.initialPrompt)
|
|
3788
|
+
// 下流 createSession と同じ options・優先順位で command を1回だけ生成する。
|
|
3789
|
+
// 失敗はこの try の catch へ流れ、worktree / tmux の副作用前に ok:false となる。
|
|
3790
|
+
if (launchPaneCli && wtCliKind === "codex") {
|
|
3791
|
+
tmuxOptions.codexCmd = tmuxOptions.codexCmd ?? buildCodexCmd(tmuxOptions)
|
|
3792
|
+
}
|
|
3793
|
+
const { wtName, wtPath, createdBranch } = await worktreeCreate(dir, branch)
|
|
3794
|
+
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
3795
|
+
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
3796
|
+
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
3797
|
+
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
3798
|
+
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
3799
|
+
const wtSessionName = sessionNameFor(wtName, wtCliKind)
|
|
3800
|
+
await tmuxCreate(wtSessionName, wtPath, tmuxOptions)
|
|
3746
3801
|
tmuxSessionsSnapshotCache.clear()
|
|
3747
3802
|
ctx.client.send({
|
|
3748
3803
|
type: "worktree.create.result",
|
|
@@ -3767,7 +3822,11 @@ export async function handleWorktreeCreate(msg, ctx) {
|
|
|
3767
3822
|
}
|
|
3768
3823
|
}
|
|
3769
3824
|
|
|
3770
|
-
|
|
3825
|
+
/**
|
|
3826
|
+
* DispatchScheduler から呼ばれる本番メッセージ入口。
|
|
3827
|
+
* export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
|
|
3828
|
+
*/
|
|
3829
|
+
export async function dispatch(msg, ctx) {
|
|
3771
3830
|
const t = msg?.type || ""
|
|
3772
3831
|
try {
|
|
3773
3832
|
switch (t) {
|
|
@@ -4144,7 +4203,7 @@ async function dispatch(msg, ctx) {
|
|
|
4144
4203
|
// (authenticate の応答で callbackExpected: false を返す)
|
|
4145
4204
|
// - clearauth: 対応する RPC が無い (CLI の `codex mcp logout` のみ)
|
|
4146
4205
|
const request_id = msg.request_id
|
|
4147
|
-
if (!ctx.codexMcpControl) {
|
|
4206
|
+
if (!ctx.codexMcpControl || !codexAvailableForDispatch(ctx)) {
|
|
4148
4207
|
ctx.client.send({
|
|
4149
4208
|
type: "codex.mcp.response",
|
|
4150
4209
|
request_id,
|
|
@@ -4268,7 +4327,7 @@ async function dispatch(msg, ctx) {
|
|
|
4268
4327
|
// (accept/decline 等) ため、正規化せず browser が組み立てたものをそのまま渡す。
|
|
4269
4328
|
case "codex.attach": {
|
|
4270
4329
|
const stream_id = msg.stream_id
|
|
4271
|
-
if (!ctx.codexBridge) {
|
|
4330
|
+
if (!ctx.codexBridge || !codexAvailableForDispatch(ctx)) {
|
|
4272
4331
|
ctx.client.send({
|
|
4273
4332
|
type: "codex.error",
|
|
4274
4333
|
stream_id,
|
|
@@ -4482,7 +4541,8 @@ async function dispatch(msg, ctx) {
|
|
|
4482
4541
|
// (env の揮発値に依存しない)、epoch と解決済み文脈をエコーして frontend の
|
|
4483
4542
|
// 単一 read ゲートが stale/逆順/別プロファイル応答を判別できるようにする。
|
|
4484
4543
|
const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
|
|
4485
|
-
const
|
|
4544
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
4545
|
+
const { sessionsDir, profileId } = await profileApi.resolveSessionsDir(
|
|
4486
4546
|
msg.profile_id,
|
|
4487
4547
|
)
|
|
4488
4548
|
if (!ctx.codexBridge) {
|
|
@@ -4799,9 +4859,10 @@ async function dispatch(msg, ctx) {
|
|
|
4799
4859
|
// 分離のため CODEX_HOME 切替だけでアカウントが変わる (スワップ不要)。
|
|
4800
4860
|
const id = msg.profile_id || msg.id || ""
|
|
4801
4861
|
try {
|
|
4802
|
-
const
|
|
4803
|
-
|
|
4804
|
-
|
|
4862
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
4863
|
+
const target = await profileApi.setActiveProfile(id)
|
|
4864
|
+
;(ctx.applyActiveCodexEnv || applyActiveCodexEnv)(target, ctx.logger)
|
|
4865
|
+
await (ctx.syncTmuxCodexEnv || syncTmuxCodexEnv)(target, ctx.logger)
|
|
4805
4866
|
// Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
|
|
4806
4867
|
const before = ctx.codexBridge?.sessions?.size ?? 0
|
|
4807
4868
|
ctx.codexBridge?.shutdown()
|
|
@@ -4810,9 +4871,16 @@ async function dispatch(msg, ctx) {
|
|
|
4810
4871
|
// 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
|
|
4811
4872
|
// usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
|
|
4812
4873
|
usageResponseCache.clear()
|
|
4813
|
-
const hasCreds = await
|
|
4874
|
+
const hasCreds = await profileApi.hasCodexCredentials(target.configDir)
|
|
4814
4875
|
const needsLogin = !hasCreds
|
|
4815
|
-
const isDefaultTarget =
|
|
4876
|
+
const isDefaultTarget =
|
|
4877
|
+
target.id === (profileApi.DEFAULT_CODEX_PROFILE_ID || codexProfiles.DEFAULT_CODEX_PROFILE_ID)
|
|
4878
|
+
const login = needsLogin
|
|
4879
|
+
? resolveCodexLoginCommand(target.configDir, {
|
|
4880
|
+
isDefault: isDefaultTarget,
|
|
4881
|
+
_resolveCodexBinary: ctx._resolveCodexBinary || resolveCodexBinary,
|
|
4882
|
+
})
|
|
4883
|
+
: { login_command: undefined }
|
|
4816
4884
|
ctx.logger.info(
|
|
4817
4885
|
{ profile_id: target.id, reaped: before, needs_login: needsLogin },
|
|
4818
4886
|
"codex profile switched; codex sessions reaped",
|
|
@@ -4829,11 +4897,7 @@ async function dispatch(msg, ctx) {
|
|
|
4829
4897
|
},
|
|
4830
4898
|
reaped: before,
|
|
4831
4899
|
needs_login: needsLogin,
|
|
4832
|
-
|
|
4833
|
-
? isDefaultTarget
|
|
4834
|
-
? "codex login"
|
|
4835
|
-
: `CODEX_HOME=${target.configDir} codex login`
|
|
4836
|
-
: undefined,
|
|
4900
|
+
...login,
|
|
4837
4901
|
})
|
|
4838
4902
|
} catch (err) {
|
|
4839
4903
|
ctx.client.send({
|
|
@@ -4850,7 +4914,11 @@ async function dispatch(msg, ctx) {
|
|
|
4850
4914
|
// 新規 Codex プロファイル追加 (連番 id・中立パス `~/.codex-<n>`)。共有資産を
|
|
4851
4915
|
// provisioning 済み。追加直後はログイン未了 (CODEX_HOME=<dir> codex login)。
|
|
4852
4916
|
try {
|
|
4853
|
-
const
|
|
4917
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
4918
|
+
const created = await profileApi.addProfile({ label: msg.label })
|
|
4919
|
+
const login = resolveCodexLoginCommand(created.configDir, {
|
|
4920
|
+
_resolveCodexBinary: ctx._resolveCodexBinary || resolveCodexBinary,
|
|
4921
|
+
})
|
|
4854
4922
|
ctx.client.send({
|
|
4855
4923
|
type: "codex.profile.add.result",
|
|
4856
4924
|
request_id: msg.request_id,
|
|
@@ -4861,10 +4929,10 @@ async function dispatch(msg, ctx) {
|
|
|
4861
4929
|
config_dir: created.configDir,
|
|
4862
4930
|
},
|
|
4863
4931
|
needs_login: created.needsLogin,
|
|
4864
|
-
|
|
4932
|
+
...login,
|
|
4865
4933
|
})
|
|
4866
4934
|
// 新プロファイルへ bundle hooks / AGENTS.md を反映 (codex 向け setup)。
|
|
4867
|
-
kickSetupHubAi({ logger: ctx.logger, cliKind: "codex" })
|
|
4935
|
+
;(ctx.kickSetupHubAi || kickSetupHubAi)({ logger: ctx.logger, cliKind: "codex" })
|
|
4868
4936
|
} catch (err) {
|
|
4869
4937
|
ctx.client.send({
|
|
4870
4938
|
type: "codex.profile.add.result",
|
|
@@ -4879,7 +4947,8 @@ async function dispatch(msg, ctx) {
|
|
|
4879
4947
|
// Codex プロファイルの表示ラベルのみ変更 (切替・認証に触れない)。
|
|
4880
4948
|
const id = msg.profile_id || msg.id || ""
|
|
4881
4949
|
try {
|
|
4882
|
-
const
|
|
4950
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
4951
|
+
const updated = await profileApi.renameProfile(id, msg.label)
|
|
4883
4952
|
ctx.client.send({
|
|
4884
4953
|
type: "codex.profile.rename.result",
|
|
4885
4954
|
request_id: msg.request_id,
|