@cocorograph/hub-agent 0.7.25 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/config.mjs CHANGED
@@ -10,6 +10,10 @@
10
10
  import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
+ import { execFile } from "node:child_process"
14
+ import { promisify } from "node:util"
15
+
16
+ const _execFileP = promisify(execFile)
13
17
 
14
18
  /**
15
19
  * 実行プラットフォームを返す。Hub の UI が OS 別コマンドを出し分けるために使う。
@@ -35,6 +39,34 @@ export function detectPlatform() {
35
39
  return process.platform || "unknown"
36
40
  }
37
41
 
42
+ /** このホストで起動可否を検出する CLI 種別の候補。 */
43
+ export const CLI_CANDIDATES = ["claude", "codex"]
44
+
45
+ /**
46
+ * PATH 上に存在する CLI 種別 (capability) を検出する。Cockpit が「この hub-agent で
47
+ * どの CLI を起動できるか」を知るため、起動時に 1 回呼んで enroll/hello payload で
48
+ * Hub に広告する (`CockpitAgent.available_clis`)。which/where が成功したものだけ返す。
49
+ *
50
+ * @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any>}} [opts]
51
+ * exec はテスト差し替え用 (既定は which/where を叩く promisified execFile)。
52
+ * @returns {Promise<string[]>} 例: ["claude", "codex"]
53
+ */
54
+ export async function detectAvailableClis(opts = {}) {
55
+ const candidates = opts.candidates || CLI_CANDIDATES
56
+ const exec = opts.exec || _execFileP
57
+ const lookup = process.platform === "win32" ? "where" : "which"
58
+ const found = []
59
+ for (const cli of candidates) {
60
+ try {
61
+ await exec(lookup, [cli])
62
+ found.push(cli)
63
+ } catch {
64
+ /* PATH に無い = 未インストール */
65
+ }
66
+ }
67
+ return found
68
+ }
69
+
38
70
  function resolveConfigDir() {
39
71
  if (process.env.HUB_AGENT_CONFIG_DIR) return process.env.HUB_AGENT_CONFIG_DIR
40
72
  return path.join(os.homedir(), ".hub")
package/src/enroll.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import os from "node:os"
10
10
 
11
- import { detectPlatform, hasConfig, writeConfig } from "./config.mjs"
11
+ import { detectAvailableClis, detectPlatform, hasConfig, writeConfig } from "./config.mjs"
12
12
 
13
13
  const DEFAULT_HUB_URL = process.env.HUB_URL || "https://hub.cocorograph.com"
14
14
 
@@ -34,6 +34,8 @@ export async function enroll(enrollmentToken, opts = {}) {
34
34
  throw new Error("global fetch がありません。Node 20+ で実行してください。")
35
35
  }
36
36
 
37
+ const availableClis = await detectAvailableClis()
38
+
37
39
  const res = await fetchImpl(`${hubUrl}/api/cockpit/agents/enroll-complete/`, {
38
40
  method: "POST",
39
41
  headers: { "content-type": "application/json" },
@@ -42,6 +44,7 @@ export async function enroll(enrollmentToken, opts = {}) {
42
44
  hostname,
43
45
  version,
44
46
  platform: detectPlatform(),
47
+ available_clis: availableClis,
45
48
  }),
46
49
  })
47
50
 
package/src/main.mjs CHANGED
@@ -19,7 +19,7 @@ import path from "node:path"
19
19
 
20
20
  import pino from "pino"
21
21
 
22
- import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
22
+ import { detectAvailableClis, detectPlatform, readConfig, writeConfig } from "./config.mjs"
23
23
  import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
24
24
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
25
25
  import { WsClient } from "./ws-client.mjs"
@@ -442,10 +442,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
442
442
  logger.info({ bundleVersion }, "hub bundle version detected")
443
443
  }
444
444
 
445
+ // ホストで起動可能な CLI (claude / codex) を 1 回検出して hello で広告する。
446
+ const availableClis = await detectAvailableClis()
447
+ logger.info({ availableClis }, "available CLIs detected")
448
+
445
449
  const client = new WsClient(config, {
446
450
  logger,
447
451
  version,
448
452
  platform: detectPlatform(),
453
+ availableClis,
449
454
  bundleVersion,
450
455
  bundleVersionProvider: readBundleVersionSync,
451
456
  bundleManifestPath: BUNDLE_MANIFEST_PATH,
@@ -3224,10 +3229,19 @@ async function dispatch(msg, ctx) {
3224
3229
  }
3225
3230
  try {
3226
3231
  await createTmuxSession(name, cwd, {
3232
+ // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
3233
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3227
3234
  claudeCmd:
3228
3235
  typeof msg.claude_cmd === "string"
3229
3236
  ? msg.claude_cmd
3230
3237
  : claudeCmdFromAgentConfig(ctx.config),
3238
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3239
+ // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
3240
+ model: typeof msg.model === "string" ? msg.model : undefined,
3241
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3242
+ approvalPolicy:
3243
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3244
+ effort: typeof msg.effort === "string" ? msg.effort : undefined,
3231
3245
  initialPrompt:
3232
3246
  typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3233
3247
  logger: ctx.logger,
package/src/tmux.mjs CHANGED
@@ -840,16 +840,29 @@ export async function createSession(name, cwd, opts = {}) {
840
840
  // display-message 失敗は致命的でないので飲み込む
841
841
  }
842
842
  }
843
- const claudeCmdBase = opts.claudeCmd ?? DEFAULT_CLAUDE_CMD
844
- // アカウント・プロファイル切替 (CLAUDE_CONFIG_DIR) を TUI セッションにも効かせる。
845
- // applyActiveProfileEnv process.env を最新に保つため、既定では env から拾う。
846
- const configDir = opts.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? null
847
- const claudeCmd = injectInitialPrompt(
848
- injectConfigDirEnv(claudeCmdBase, configDir),
849
- opts.initialPrompt,
850
- )
851
- if (claudeCmd) {
852
- await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
843
+ // cliKind に応じて claude / codex いずれの CLI を pane で起動するか分岐する。
844
+ // 既定は claude(後方互換)。initialPrompt はどちらも位置引数として末尾注入する
845
+ // (claude "<p>" / codex "<p>" / codex resume <id> "<p>" いずれも [PROMPT] を受ける)。
846
+ const launchCmd =
847
+ opts.cliKind === "codex"
848
+ ? injectInitialPrompt(
849
+ injectCodexHomeEnv(
850
+ opts.codexCmd ?? buildCodexCmd(opts),
851
+ opts.codexHome ?? process.env.CODEX_HOME ?? null,
852
+ ),
853
+ opts.initialPrompt,
854
+ )
855
+ : injectInitialPrompt(
856
+ // アカウント・プロファイル切替 (CLAUDE_CONFIG_DIR) を TUI セッションにも効かせる。
857
+ // applyActiveProfileEnv が process.env を最新に保つため、既定では env から拾う。
858
+ injectConfigDirEnv(
859
+ opts.claudeCmd ?? DEFAULT_CLAUDE_CMD,
860
+ opts.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? null,
861
+ ),
862
+ opts.initialPrompt,
863
+ )
864
+ if (launchCmd) {
865
+ await execFileP(tmuxBin(opts), ["send-keys", "-t", name, launchCmd, "Enter"])
853
866
  }
854
867
  }
855
868
 
@@ -932,6 +945,99 @@ export function buildFreshCmd(opts = {}) {
932
945
  return `claude${flags ? " " + flags : ""}`.trim()
933
946
  }
934
947
 
948
+ // ---------------------------------------------------------------------------
949
+ // Codex CLI 起動コマンドビルダ (claude 版と同形の純粋関数)
950
+ //
951
+ // 設計メモ:
952
+ // - codex の対話起動フラグ (v0.142.x 実機確認): `-m/--model` / `-s/--sandbox`
953
+ // {read-only|workspace-write|danger-full-access} / `-a/--ask-for-approval`
954
+ // {untrusted|on-request|never} / `-C/--cd` / `-p/--profile`。
955
+ // - 値は安全なトークン (モデル名・enum) を前提に bare で並べる (claude 版と同方針)。
956
+ // - codex は claude の `--continue` のような **cwd スコープ継続を持たない**
957
+ // (rollout は ~/.codex/sessions/Y/M/D に日付シャードで保存され cwd 非依存)。
958
+ // そのため既定は fresh 起動とし、継続は明示 session_id で resume する。
959
+ // - アカウント/プロファイル切替は CLAUDE_CONFIG_DIR に対応する `CODEX_HOME` の
960
+ // ディレクトリ切替で行う (injectCodexHomeEnv)。
961
+ // ---------------------------------------------------------------------------
962
+
963
+ /**
964
+ * agent 設定から codex 起動 flags を組み立てる (純粋関数)。
965
+ * model / sandbox / approvalPolicy / effort のうち指定されたものだけを並べる。
966
+ * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
967
+ * @returns {string}
968
+ */
969
+ export function composeCodexFlags(opts = {}) {
970
+ const model = (opts.model || "").trim()
971
+ const sandbox = (opts.sandbox || "").trim()
972
+ const approval = (opts.approvalPolicy || "").trim()
973
+ const effort = (opts.effort || "").trim()
974
+ const flags = []
975
+ if (model) flags.push(`--model ${model}`)
976
+ if (sandbox) flags.push(`--sandbox ${sandbox}`)
977
+ if (approval) flags.push(`--ask-for-approval ${approval}`)
978
+ // effort はフラグが無いため -c 設定上書きで渡す (TOML 解析失敗時は raw 文字列)。
979
+ if (effort) flags.push(`-c model_reasoning_effort=${effort}`)
980
+ return flags.join(" ")
981
+ }
982
+
983
+ /**
984
+ * codex 既定起動コマンド (claude の buildClaudeCmd 相当)。
985
+ * hub オプション (model/sandbox/approval/effort) があれば反映、無ければ
986
+ * HUB_CODEX_CMD 環境変数、どちらも無ければ最小デフォルト `codex`。
987
+ * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
988
+ * @returns {string}
989
+ */
990
+ export function buildCodexCmd(opts = {}) {
991
+ const flags = composeCodexFlags(opts)
992
+ if (flags) return `codex ${flags}`.trim()
993
+ if (process.env.HUB_CODEX_CMD) return process.env.HUB_CODEX_CMD
994
+ return "codex"
995
+ }
996
+
997
+ /**
998
+ * まっさらな新規 codex セッション起動用コマンド (純粋関数)。
999
+ * resume を付けないので codex は新しい rollout で起動する。
1000
+ * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
1001
+ * @returns {string}
1002
+ */
1003
+ export function buildCodexFreshCmd(opts = {}) {
1004
+ const flags = composeCodexFlags(opts)
1005
+ return `codex${flags ? " " + flags : ""}`.trim()
1006
+ }
1007
+
1008
+ /**
1009
+ * TUI 載せ替え用の `codex resume <id> [flags]` コマンド (純粋関数)。
1010
+ * session_id を明示 resume することで表示と書込の rollout を一致させる。
1011
+ * @param {string} sessionId codex セッション id (UUID 等)
1012
+ * @param {{model?:string,sandbox?:string,approvalPolicy?:string,effort?:string}} [opts]
1013
+ * @returns {string}
1014
+ * @throws session_id が安全形でなければ throw (コマンド注入防止)
1015
+ */
1016
+ export function buildCodexResumeCmd(sessionId, opts = {}) {
1017
+ if (!isSafeSessionId(sessionId)) {
1018
+ throw new Error(`unsafe session_id for codex resume: ${String(sessionId)}`)
1019
+ }
1020
+ const flags = composeCodexFlags(opts)
1021
+ return `codex resume ${sessionId}${flags ? " " + flags : ""}`.trim()
1022
+ }
1023
+
1024
+ /**
1025
+ * codexCmd の各セグメント先頭に `CODEX_HOME=<dir>` 環境変数代入を注入する
1026
+ * (claude の injectConfigDirEnv 相当)。codexHome が空なら何もしない。
1027
+ * @param {string} codexCmd
1028
+ * @param {string} codexHome
1029
+ * @returns {string}
1030
+ */
1031
+ export function injectCodexHomeEnv(codexCmd, codexHome) {
1032
+ if (!codexHome || typeof codexHome !== "string") return codexCmd
1033
+ if (typeof codexCmd !== "string" || codexCmd.length === 0) return codexCmd
1034
+ const assignment = `CODEX_HOME=${JSON.stringify(codexHome)}`
1035
+ return codexCmd
1036
+ .split("||")
1037
+ .map((part) => `${assignment} ${part.trim()}`)
1038
+ .join(" || ")
1039
+ }
1040
+
935
1041
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
936
1042
 
937
1043
  /**
@@ -1772,7 +1878,12 @@ export async function rebindClaudeSession(name, sessionId, opts = {}) {
1772
1878
  let cmd
1773
1879
  try {
1774
1880
  // fresh=新規起動 (--resume 無し)。それ以外は session_id 検証込みの resume。
1775
- cmd = opts.fresh ? buildFreshCmd(opts) : buildResumeCmd(sessionId, opts)
1881
+ // cliKind に応じて claude / codex のビルダを使い分ける (既定は claude)
1882
+ if (opts.cliKind === "codex") {
1883
+ cmd = opts.fresh ? buildCodexFreshCmd(opts) : buildCodexResumeCmd(sessionId, opts)
1884
+ } else {
1885
+ cmd = opts.fresh ? buildFreshCmd(opts) : buildResumeCmd(sessionId, opts)
1886
+ }
1776
1887
  } catch (err) {
1777
1888
  return { ok: false, error: err?.message || String(err) }
1778
1889
  }
package/src/ws-client.mjs CHANGED
@@ -113,6 +113,8 @@ export class WsClient extends EventEmitter {
113
113
  this.bundleWatchDebounceTimer = null
114
114
  this.hostname = opts.hostname || os.hostname()
115
115
  this.platform = opts.platform || "unknown"
116
+ // このホストで起動可能な CLI 種別 (capability)。起動時に 1 回検出して hello で広告する。
117
+ this.availableClis = Array.isArray(opts.availableClis) ? opts.availableClis : []
116
118
  this.ws = null
117
119
  this.heartbeatTimer = null
118
120
  this.reconnectTimer = null
@@ -226,6 +228,7 @@ export class WsClient extends EventEmitter {
226
228
  version: this.version,
227
229
  bundle_version: this.bundleVersion,
228
230
  platform: this.platform,
231
+ available_clis: this.availableClis,
229
232
  })
230
233
  // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
231
234
  // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、