@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.46",
3
+ "version": "0.8.48",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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 || DEFAULT_CODEX_CMD
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(this.codexCmd, args, {
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
- * PATH 上に存在する CLI 種別 (capability) を検出する。Cockpit が「この hub-agent で
66
+ * 起動可能な CLI 種別 (capability) を検出する。Cockpit が「この hub-agent で
66
67
  * どの CLI を起動できるか」を知るため、起動時に 1 回呼んで enroll/hello payload で
67
- * Hub に広告する (`CockpitAgent.available_clis`)。which/where が成功したものだけ返す。
68
+ * Hub に広告する (`CockpitAgent.available_clis`)。Codex は共通 resolver、その他は
69
+ * which/where が成功したものだけ返す。
68
70
  *
69
- * @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any>}} [opts]
70
- * exec はテスト差し替え用 (既定は which/where を叩く promisified execFile)。
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)
@@ -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 resolveBin = resolveBinImpl || resolveCliBin
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 resolveBin("codex")
508
+ const codexBin = await resolveCodexBin("codex")
494
509
  if (!codexBin) {
495
- logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
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 listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
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 { killed } = await killManySessions(
222
- sessions.map((s) => s.name),
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 sessions) {
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
- await createTmuxSession(s.name, s.cwd, {
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: cliKindForSessionName(s.name),
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.sessions.request/codex.attach の
356
- * app-server spawn = 数秒、claude.sessions.request の全ファイル読み) が後続の claude.attach を
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 を共有するため codex の spawn が claude の切替まで巻き込む。
404
+ * claude/codex が同一 dispatchChain を共有するため一方の重い処理が他方の切替まで巻き込む。
359
405
  * 読み取り RPC を並行レーンへ逃がすことで、タイムライン閲覧が live 接続 (spawn) の完了を待たなくなる。
360
406
  *
361
- * ⚠️ codex.sessions.request は listThreads が app-server を spawn するため、off-queue 化の副作用で
362
- * 同一 cwd への同時リクエストが spawn storm を起こしうる。CodexStreamBridge.listThreads 側に
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
- // 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
762
- // gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
763
- // JS SDK が無く CLI バイナリの有無そのものが可否条件になる)。
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 ブリッジ。PATH 上に codex バイナリが無ければ
775
- // null のままにし、claudeBridge と同じ流儀で codex.attach が codex.error を返す
776
- // 経路に分岐させる (main.mjs の codex.* dispatch 側で判定)。
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
- // codex バイナリが無ければ null (codex.mcp.* は cli_unavailable を返す)。
785
- const codexMcpControl = availableClis.includes("codex")
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 { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
3716
- // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
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 wtSessionName = sessionNameFor(wtName, wtCliKind)
3723
- await createTmuxSession(wtSessionName, wtPath, {
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
- // codex 用設定 (claude 経路では無視される)。
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
- async function dispatch(msg, ctx) {
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 { sessionsDir, profileId } = await codexProfiles.resolveSessionsDir(
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 target = await codexProfiles.setActiveProfile(id)
4803
- applyActiveCodexEnv(target, ctx.logger)
4804
- await syncTmuxCodexEnv(target, ctx.logger)
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 codexProfiles.hasCodexCredentials(target.configDir)
4874
+ const hasCreds = await profileApi.hasCodexCredentials(target.configDir)
4814
4875
  const needsLogin = !hasCreds
4815
- const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
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
- login_command: needsLogin
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 created = await codexProfiles.addProfile({ label: msg.label })
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
- login_command: `CODEX_HOME=${created.configDir} codex login`,
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 updated = await codexProfiles.renameProfile(id, msg.label)
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
- * `JSON.stringify` は `"` `\` 制御文字を `\u00xx` 等で安全にエスケープし、
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 = JSON.stringify(initialPrompt)
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=${JSON.stringify(configDir)}`
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 (!shouldLaunchPaneCli()) {
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
- opts.codexCmd ?? buildCodexCmd(opts),
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 環境変数、どちらも無ければ最小デフォルト `codex`。
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 `codex ${flags}`.trim()
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
- return bypass ? `codex ${bypass}` : "codex"
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
- return `codex${flags ? " " + flags : ""}`.trim()
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
- return `codex resume ${sessionId}${flags ? " " + flags : ""}`.trim()
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=${JSON.stringify(codexHome)}`
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
- for (const { fp, mtimeMs, size } of candidates) {
1742
- if (snapshot.has(fp)) continue
1743
- const observed = index.get(fp)
1744
- parsedHeaders++
1745
- const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
1746
- if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
1747
- const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
1748
- commitCodexThreadListEntry(index, fp, observed, entry)
1749
- snapshot.set(fp, entry)
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 = []