@cocorograph/hub-agent 0.8.46 → 0.8.48
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 +22 -10
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,
|
package/src/tmux.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { detectSessionState, getSessionCwd } from "./state.mjs"
|
|
|
27
27
|
import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
|
|
28
28
|
import { baseSessionName, sessionNameFor } from "./session-names.mjs"
|
|
29
29
|
import { boundSessionId, getSessionUsages, isFreshUnboundBind, jsonlContextForCwd } from "./usage.mjs"
|
|
30
|
+
import { quotePosixShellArg, requireCodexBinary } from "./codex-binary.mjs"
|
|
30
31
|
|
|
31
32
|
const execFileP = promisify(execFile)
|
|
32
33
|
|
|
@@ -514,6 +515,19 @@ function tmuxBin(opts = {}) {
|
|
|
514
515
|
return opts.tmuxBin || DEFAULT_TMUX_BIN
|
|
515
516
|
}
|
|
516
517
|
|
|
518
|
+
/**
|
|
519
|
+
* pane CLI に渡す initialPrompt の危険な制御文字を拒否する。
|
|
520
|
+
* 対話入力として必要な tab (HT) / LF / CR は許可する。
|
|
521
|
+
*/
|
|
522
|
+
export function validateInitialPrompt(initialPrompt) {
|
|
523
|
+
if (
|
|
524
|
+
typeof initialPrompt === "string" &&
|
|
525
|
+
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(initialPrompt)
|
|
526
|
+
) {
|
|
527
|
+
throw new Error("initialPrompt contains unsupported control characters")
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
517
531
|
/**
|
|
518
532
|
* claudeCmd の各セグメント末尾に initialPrompt を shell-safe quoting で
|
|
519
533
|
* 末尾引数として注入する。
|
|
@@ -523,13 +537,13 @@ function tmuxBin(opts = {}) {
|
|
|
523
537
|
* 旧実装は `--permission-mode auto` を marker として正規表現マッチしていたが、
|
|
524
538
|
* model / permission-mode が個人ごとに変わる前提で marker 非依存に変更した。
|
|
525
539
|
*
|
|
526
|
-
*
|
|
527
|
-
* 結果は double-quoted な shell リテラルとしてそのまま使える。
|
|
540
|
+
* POSIX shell の単一 argv として quote し、`$()` / backtick 等を展開させない。
|
|
528
541
|
*/
|
|
529
542
|
export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
530
543
|
if (!initialPrompt || typeof initialPrompt !== "string") return claudeCmd
|
|
544
|
+
validateInitialPrompt(initialPrompt)
|
|
531
545
|
if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
|
|
532
|
-
const quoted =
|
|
546
|
+
const quoted = quotePosixShellArg(initialPrompt)
|
|
533
547
|
return claudeCmd
|
|
534
548
|
.split("||")
|
|
535
549
|
.map((part) => `${part.trim()} ${quoted}`)
|
|
@@ -555,7 +569,7 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
|
555
569
|
export function injectConfigDirEnv(claudeCmd, configDir) {
|
|
556
570
|
if (!configDir || typeof configDir !== "string") return claudeCmd
|
|
557
571
|
if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
|
|
558
|
-
const assignment = `CLAUDE_CONFIG_DIR=${
|
|
572
|
+
const assignment = `CLAUDE_CONFIG_DIR=${quotePosixShellArg(configDir)}`
|
|
559
573
|
return claudeCmd
|
|
560
574
|
.split("||")
|
|
561
575
|
.map((part) => `${assignment} ${part.trim()}`)
|
|
@@ -790,9 +804,16 @@ export async function killManySessions(names, opts = {}) {
|
|
|
790
804
|
* TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
|
|
791
805
|
*/
|
|
792
806
|
export async function createSession(name, cwd, opts = {}) {
|
|
807
|
+
const launchPaneCli = shouldLaunchPaneCli()
|
|
808
|
+
if (launchPaneCli) validateInitialPrompt(opts.initialPrompt)
|
|
793
809
|
// tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
|
|
794
810
|
// 展開後の cwd は同名引数で以降の検証 / tmux に渡す。
|
|
795
811
|
const resolvedCwd = expandTilde(cwd)
|
|
812
|
+
// resolver 失敗時に workspace 作成・Hub 同期・tmux 作成の副作用を残さない。
|
|
813
|
+
const prebuiltCodexBaseCmd =
|
|
814
|
+
launchPaneCli && opts.cliKind === "codex"
|
|
815
|
+
? opts.codexCmd ?? buildCodexCmd(opts)
|
|
816
|
+
: null
|
|
796
817
|
// cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
|
|
797
818
|
// 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
|
|
798
819
|
// fallback して、SessionStart hook が project を解決できなくなる。
|
|
@@ -957,7 +978,7 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
957
978
|
// 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
|
|
958
979
|
// `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
|
|
959
980
|
// ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
|
|
960
|
-
if (!
|
|
981
|
+
if (!launchPaneCli) {
|
|
961
982
|
opts.logger?.debug?.(
|
|
962
983
|
{ session: name, cwd: resolvedCwd },
|
|
963
984
|
"pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
|
|
@@ -971,7 +992,7 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
971
992
|
opts.cliKind === "codex"
|
|
972
993
|
? injectInitialPrompt(
|
|
973
994
|
injectCodexHomeEnv(
|
|
974
|
-
|
|
995
|
+
prebuiltCodexBaseCmd,
|
|
975
996
|
opts.codexHome ?? process.env.CODEX_HOME ?? null,
|
|
976
997
|
),
|
|
977
998
|
opts.initialPrompt,
|
|
@@ -1041,6 +1062,15 @@ function codexHookTrustBypassFlag() {
|
|
|
1041
1062
|
return process.env.HUB_CODEX_BYPASS_HOOK_TRUST !== "0" ? "--dangerously-bypass-hook-trust" : ""
|
|
1042
1063
|
}
|
|
1043
1064
|
|
|
1065
|
+
function codexShellBinary(opts = {}) {
|
|
1066
|
+
const resolver = opts._resolveCodexBinary || requireCodexBinary
|
|
1067
|
+
const resolved = resolver()
|
|
1068
|
+
if (!resolved?.available || !resolved.path) {
|
|
1069
|
+
throw new Error(resolved?.error || "codex CLI is unavailable")
|
|
1070
|
+
}
|
|
1071
|
+
return quotePosixShellArg(resolved.path)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1044
1074
|
/**
|
|
1045
1075
|
* agent 設定から codex 起動 flags を組み立てる (純粋関数)。
|
|
1046
1076
|
* model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べ、
|
|
@@ -1068,7 +1098,7 @@ export function composeCodexFlags(opts = {}) {
|
|
|
1068
1098
|
/**
|
|
1069
1099
|
* codex 既定起動コマンド (claude の buildClaudeCmd 相当)。
|
|
1070
1100
|
* hub オプション (model/sandbox/approval/effort) があれば反映、無ければ
|
|
1071
|
-
* HUB_CODEX_CMD
|
|
1101
|
+
* HUB_CODEX_CMD 環境変数、どちらも無ければ共通 resolver の実行ファイル。
|
|
1072
1102
|
* ⚠️ hub オプション有無に関わらず `--dangerously-bypass-hook-trust` は常時付与する
|
|
1073
1103
|
* (hub オプション判定と bypass flag 判定を分離しているため、HUB_CODEX_CMD
|
|
1074
1104
|
* 分岐を選んでも bypass flag は付く。詳細は `codexHookTrustBypassFlag` 参照)。
|
|
@@ -1085,12 +1115,13 @@ export function buildCodexCmd(opts = {}) {
|
|
|
1085
1115
|
|
|
1086
1116
|
if (hasHubOptions) {
|
|
1087
1117
|
const flags = composeCodexFlags(opts)
|
|
1088
|
-
return
|
|
1118
|
+
return `${codexShellBinary(opts)} ${flags}`.trim()
|
|
1089
1119
|
}
|
|
1090
1120
|
if (process.env.HUB_CODEX_CMD) {
|
|
1091
1121
|
return `${process.env.HUB_CODEX_CMD}${bypass ? ` ${bypass}` : ""}`.trim()
|
|
1092
1122
|
}
|
|
1093
|
-
|
|
1123
|
+
const codex = codexShellBinary(opts)
|
|
1124
|
+
return bypass ? `${codex} ${bypass}` : codex
|
|
1094
1125
|
}
|
|
1095
1126
|
|
|
1096
1127
|
/**
|
|
@@ -1101,7 +1132,8 @@ export function buildCodexCmd(opts = {}) {
|
|
|
1101
1132
|
*/
|
|
1102
1133
|
export function buildCodexFreshCmd(opts = {}) {
|
|
1103
1134
|
const flags = composeCodexFlags(opts)
|
|
1104
|
-
|
|
1135
|
+
const codex = codexShellBinary(opts)
|
|
1136
|
+
return `${codex}${flags ? " " + flags : ""}`.trim()
|
|
1105
1137
|
}
|
|
1106
1138
|
|
|
1107
1139
|
/**
|
|
@@ -1117,7 +1149,8 @@ export function buildCodexResumeCmd(sessionId, opts = {}) {
|
|
|
1117
1149
|
throw new Error(`unsafe session_id for codex resume: ${String(sessionId)}`)
|
|
1118
1150
|
}
|
|
1119
1151
|
const flags = composeCodexFlags(opts)
|
|
1120
|
-
|
|
1152
|
+
const codex = codexShellBinary(opts)
|
|
1153
|
+
return `${codex} resume ${sessionId}${flags ? " " + flags : ""}`.trim()
|
|
1121
1154
|
}
|
|
1122
1155
|
|
|
1123
1156
|
/**
|
|
@@ -1130,7 +1163,7 @@ export function buildCodexResumeCmd(sessionId, opts = {}) {
|
|
|
1130
1163
|
export function injectCodexHomeEnv(codexCmd, codexHome) {
|
|
1131
1164
|
if (!codexHome || typeof codexHome !== "string") return codexCmd
|
|
1132
1165
|
if (typeof codexCmd !== "string" || codexCmd.length === 0) return codexCmd
|
|
1133
|
-
const assignment = `CODEX_HOME=${
|
|
1166
|
+
const assignment = `CODEX_HOME=${quotePosixShellArg(codexHome)}`
|
|
1134
1167
|
return codexCmd
|
|
1135
1168
|
.split("||")
|
|
1136
1169
|
.map((part) => `${assignment} ${part.trim()}`)
|
package/src/usage.mjs
CHANGED
|
@@ -1489,6 +1489,7 @@ const _codexThreadListIndex = new Map() // base → Map<fp, {mtimeMs,size,lastSe
|
|
|
1489
1489
|
const _codexThreadListLastWarnAt = new Map() // base → wall-clock ms
|
|
1490
1490
|
const CODEX_THREAD_LIST_LOG_INTERVAL_MS = 5 * 60 * 1000
|
|
1491
1491
|
const CODEX_THREAD_LIST_STALE_ENTRY_MS = 24 * 60 * 60 * 1000
|
|
1492
|
+
const CODEX_THREAD_LIST_HEADER_PARSE_CONCURRENCY = 16
|
|
1492
1493
|
const CODEX_ROLLOUT_PARSE_IO_ERROR = Symbol("codex-rollout-parse-io-error")
|
|
1493
1494
|
|
|
1494
1495
|
function sameCodexThreadListFingerprint(a, b) {
|
|
@@ -1736,18 +1737,29 @@ export async function listCodexThreadsFromDisk({
|
|
|
1736
1737
|
// 4. 新規・変更分だけ header を解析する。別 cwd は session_meta を得た時点で止め、
|
|
1737
1738
|
// その cwd が後で要求された時に一度だけ preview まで補完する。一時 I/O 失敗は cache せず、
|
|
1738
1739
|
// 次 poll で再試行する。解析済み不正 header (meta=null) は fingerprint 不変なら再解析しない。
|
|
1739
|
-
let parsedHeaders = 0
|
|
1740
1740
|
const headerParseStartedAt = Date.now()
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1741
|
+
const uncachedCandidates = candidates.filter(({ fp }) => !snapshot.has(fp))
|
|
1742
|
+
const parsedHeaders = uncachedCandidates.length
|
|
1743
|
+
let nextHeaderIndex = 0
|
|
1744
|
+
const parseNextHeader = async () => {
|
|
1745
|
+
while (nextHeaderIndex < uncachedCandidates.length) {
|
|
1746
|
+
const { fp, mtimeMs, size } = uncachedCandidates[nextHeaderIndex++]
|
|
1747
|
+
const observed = index.get(fp)
|
|
1748
|
+
const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
|
|
1749
|
+
if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
|
|
1750
|
+
const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
|
|
1751
|
+
commitCodexThreadListEntry(index, fp, observed, entry)
|
|
1752
|
+
snapshot.set(fp, entry)
|
|
1753
|
+
}
|
|
1750
1754
|
}
|
|
1755
|
+
await Promise.all(
|
|
1756
|
+
Array.from(
|
|
1757
|
+
{
|
|
1758
|
+
length: Math.min(CODEX_THREAD_LIST_HEADER_PARSE_CONCURRENCY, uncachedCandidates.length),
|
|
1759
|
+
},
|
|
1760
|
+
parseNextHeader,
|
|
1761
|
+
),
|
|
1762
|
+
)
|
|
1751
1763
|
|
|
1752
1764
|
// 5. cwd で絞ってから mtime 降順・limit を適用する。グローバル候補数には左右されない。
|
|
1753
1765
|
const out = []
|