@cocorograph/hub-agent 0.7.62 → 0.7.64

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/bin/hub-agent.mjs CHANGED
@@ -27,7 +27,7 @@ if (!process.env.LC_ALL) process.env.LC_ALL = "C.UTF-8"
27
27
 
28
28
  import { hasConfig, readConfig } from "../src/config.mjs"
29
29
  import { enroll } from "../src/enroll.mjs"
30
- import { syncBundle } from "../src/hub-bundle.mjs"
30
+ import { resolveSyncTargets, syncBundleTargets } from "../src/hub-bundle.mjs"
31
31
  import {
32
32
  installPlugin,
33
33
  listInstallableExamples,
@@ -110,18 +110,29 @@ program
110
110
  }
111
111
  targets = [opts.cliKind]
112
112
  } else {
113
+ // 対象決定は enroll / self-heal と共通の resolveSyncTargets に寄せる。
113
114
  const { detectAvailableClis } = await import("../src/config.mjs")
114
- const detected = (await detectAvailableClis()).filter((c) => c === "claude" || c === "codex")
115
- targets = detected.length > 0 ? detected : ["claude"]
115
+ targets = resolveSyncTargets(await detectAvailableClis())
116
116
  }
117
117
 
118
+ // 各 CLI を **setup 完了まで同期的に**処理する(純関数 syncBundleTargets に分離)。
119
+ // 一方の失敗で他方を止めず、失敗した CLI は成功メッセージを出さない。
120
+ const { results, failures } = await syncBundleTargets({
121
+ targets,
122
+ hubUrl: cfg.hub_url,
123
+ agentId: cfg.agent_id,
124
+ agentToken: cfg.agent_token,
125
+ })
118
126
  for (const cliKind of targets) {
119
- const r = await syncBundle({
120
- hubUrl: cfg.hub_url,
121
- agentId: cfg.agent_id,
122
- agentToken: cfg.agent_token,
123
- cliKind,
124
- })
127
+ const r = results[cliKind]
128
+ if (!r || r.error) {
129
+ // 失敗した CLI は success 行を出さない(reject が正しく届いている証跡)。
130
+ console.error(
131
+ `sync-bundle failed (${cliKind}): ${r?.error ?? "unknown"}` +
132
+ (r?.exitCode != null ? ` (exit ${r.exitCode})` : ""),
133
+ )
134
+ continue
135
+ }
125
136
  const mcpSummary = r.mcp
126
137
  ? ` mcp_added=${r.mcp.added.length}${r.mcp.skipped.length > 0 ? ` mcp_skipped=${r.mcp.skipped.length}` : ""}`
127
138
  : ""
@@ -132,6 +143,10 @@ program
132
143
  console.log(` mcp servers added: ${r.mcp.added.join(", ")}`)
133
144
  }
134
145
  }
146
+ if (failures.length > 0) {
147
+ console.error(`sync-bundle 失敗: ${failures.join(", ")}`)
148
+ process.exit(1)
149
+ }
135
150
  } catch (err) {
136
151
  console.error(`sync-bundle failed: ${err.message}`)
137
152
  process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.62",
3
+ "version": "0.7.64",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -646,30 +646,67 @@ do_enroll() {
646
646
  # マーカーは通常 syncBundle の最終ステップで書かれるが、scripts/manifest.json
647
647
  # が無いと SessionStart hook / hub-helper.py が動かないため両方必須。どちらかが
648
648
  # 欠けていれば半完了状態とみなして sync-bundle を retry。
649
- if _hub_bundle_incomplete; then
650
- color_warn "Hub AI bundle が ~/.claude に完全展開されていない可能性。sync-bundle を再試行"
651
- if ! retry 2 hub-agent sync-bundle; then
652
- color_warn "hub-agent sync-bundle が継続失敗。あとで手動実行してください:"
653
- color_warn " hub-agent sync-bundle"
654
- fi
655
- fi
649
+ # 検出された Claude/Codex 双方を個別検証し、不完全な CLI だけ再同期する
650
+ # (2026-07-15。旧実装は ~/.claude しか見ず Codex の欠損を検出・修復できなかった)。
651
+ _hub_reconcile_bundles
656
652
  }
657
653
 
658
- # Hub AI bundle の展開状況を判定する。完全に展開されていれば 0、欠けていれば 1。
659
- # do_enroll の事後 verify と verify_setup の最終チェックで共通利用する。
660
- _hub_bundle_incomplete() {
661
- if [[ ! -f "$HOME/.claude/CLAUDE.md" ]]; then
662
- return 0
654
+ # 検証対象の CLI を列挙する。**PATH で検出された CLI だけ**を対象にし、どちらも未検出の
655
+ # ときのみ claude にフォールバックする(JS 側 resolveSyncTargets と一致。2026-07-15 差し戻し対応。
656
+ # 旧実装は claude を常に対象にしていたため、Codex-only 環境で不要な Claude bundle 欠損警告・
657
+ # bootstrap が走っていた)。各行 "cli_kind:dir:config_md:hooks_file"。
658
+ _hub_target_clis() {
659
+ local any=0
660
+ if present claude; then
661
+ echo "claude:.claude:CLAUDE.md:settings.json"
662
+ any=1
663
663
  fi
664
- if ! grep -q "BEGIN HUB-AI-RULES" "$HOME/.claude/CLAUDE.md" 2>/dev/null; then
665
- return 0
664
+ if present codex; then
665
+ echo "codex:.codex:AGENTS.md:hooks.json"
666
+ any=1
666
667
  fi
667
- if [[ ! -f "$HOME/.claude/scripts/manifest.json" ]]; then
668
- return 0
668
+ if [ "$any" -eq 0 ]; then
669
+ echo "claude:.claude:CLAUDE.md:settings.json"
669
670
  fi
671
+ }
672
+
673
+ # 単一 CLI の bundle 完全性を判定する。完全なら 0、欠けていれば 1。
674
+ # $1=dir(.claude/.codex) $2=config_md(CLAUDE.md/AGENTS.md) $3=hooks_file(settings.json/hooks.json)
675
+ # 検証項目: scripts/manifest.json / CONFIG_MD の HUB-AI-RULES マーカー /
676
+ # hooks 設定に Hub SessionStart hook(session_start.py) / 正規 .hub_token.json
677
+ _hub_bundle_incomplete_cli() {
678
+ local dir="$1" config_md="$2" hooks_file="$3"
679
+ local base="$HOME/$dir"
680
+ [[ -f "$base/scripts/manifest.json" ]] || return 0
681
+ [[ -f "$base/$config_md" ]] || return 0
682
+ grep -q "BEGIN HUB-AI-RULES" "$base/$config_md" 2>/dev/null || return 0
683
+ grep -q "session_start.py" "$base/$hooks_file" 2>/dev/null || return 0
684
+ [[ -f "$base/.hub_token.json" ]] || return 0
670
685
  return 1
671
686
  }
672
687
 
688
+ # 後方互換: 従来 claude 前提で呼んでいた箇所向けの薄いラッパ。
689
+ _hub_bundle_incomplete() {
690
+ _hub_bundle_incomplete_cli ".claude" "CLAUDE.md" "settings.json"
691
+ }
692
+
693
+ # 検出された各 CLI の bundle 完全性を検証し、**不完全な CLI だけ** を
694
+ # `hub-agent sync-bundle --cli-kind <kind>` で再同期する(Codex 未インストールなら対象外)。
695
+ _hub_reconcile_bundles() {
696
+ local line cli_kind dir config_md hooks_file
697
+ while IFS= read -r line; do
698
+ [[ -z "$line" ]] && continue
699
+ IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
700
+ if _hub_bundle_incomplete_cli "$dir" "$config_md" "$hooks_file"; then
701
+ color_warn "Hub AI bundle が ~/$dir に完全展開されていない可能性 ($cli_kind)。sync-bundle を再試行"
702
+ if ! retry 2 hub-agent sync-bundle --cli-kind "$cli_kind"; then
703
+ color_warn "hub-agent sync-bundle --cli-kind $cli_kind が継続失敗。あとで手動実行してください:"
704
+ color_warn " hub-agent sync-bundle --cli-kind $cli_kind"
705
+ fi
706
+ fi
707
+ done < <(_hub_target_clis)
708
+ }
709
+
673
710
  do_install_service() {
674
711
  color_step "OS サービスとして自動起動を登録"
675
712
  # install-service は launchctl bootout/bootstrap の transient で初回失敗する
@@ -687,17 +724,26 @@ do_install_service() {
687
724
  # verify を成功させる。setup_hub_ai 不在 (bundle 同期失敗) のときは色付き
688
725
  # warn を出して継続。再実行時は step_* が冪等処理を返すだけで害なし。
689
726
  do_bootstrap_hub_ai() {
690
- local setup_script="$HOME/.claude/scripts/setup_hub_ai.py"
691
- if [[ ! -f "$setup_script" ]]; then
692
- color_warn "setup_hub_ai.py 不在 (bundle 同期が失敗した可能性)。スキップ"
693
- return 0
694
- fi
695
- color_step "setup_hub_ai.py --silent を実行 (~/.claude/CLAUDE.md / settings.json 初期化)"
696
- if python3 "$setup_script" --silent; then
697
- color_ok "Hub AI bootstrap 完了"
698
- else
699
- color_warn "setup_hub_ai.py --silent が exit≠0 で終了 (継続)。手動で再実行: python3 $setup_script"
700
- fi
727
+ # 検出された各 CLI(claude 常に / codex は present 時)の setup_hub_ai.py を
728
+ # 同期実行する。enroll/sync 内でも setup は走るが、install.sh では verify を確実に
729
+ # 成功させるため各 CLI で同期実行する(一方の失敗で他方を止めない)。2026-07-15 に
730
+ # Claude 固定から双方へ拡張。
731
+ local line cli_kind dir config_md hooks_file setup_script
732
+ while IFS= read -r line; do
733
+ [[ -z "$line" ]] && continue
734
+ IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
735
+ setup_script="$HOME/$dir/scripts/setup_hub_ai.py"
736
+ if [[ ! -f "$setup_script" ]]; then
737
+ color_warn "setup_hub_ai.py 不在 ($cli_kind, bundle 同期が失敗した可能性)。スキップ"
738
+ continue
739
+ fi
740
+ color_step "setup_hub_ai.py --silent を実行 ($cli_kind: ~/$dir/$config_md / $hooks_file 初期化)"
741
+ if python3 "$setup_script" --silent; then
742
+ color_ok "Hub AI bootstrap 完了 ($cli_kind)"
743
+ else
744
+ color_warn "setup_hub_ai.py --silent が exit≠0 ($cli_kind, 継続)。手動再実行: python3 $setup_script"
745
+ fi
746
+ done < <(_hub_target_clis)
701
747
  }
702
748
 
703
749
  # セットアップ最終検証。
@@ -721,22 +767,22 @@ verify_setup() {
721
767
  color_warn "Claude Code (claude) コマンドが見つかりません (cockpit から claude を呼ぶ場合は必要)"
722
768
  fi
723
769
 
724
- # 3. Hub AI bundle 展開 (do_enroll と同じ 2 段チェック: CLAUDE.md マーカー +
725
- # scripts/manifest.json)。CLAUDE.md は書き込まれたが manifest.json が落ちて
726
- # いない半完了状態も検知する。
727
- if ! _hub_bundle_incomplete; then
728
- local mver
729
- mver="$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.claude/scripts/manifest.json" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
730
- color_ok "Hub AI bundle 展開済み (~/.claude/CLAUDE.md + scripts/manifest.json ${mver:+v$mver})"
731
- else
732
- if [[ ! -f "$HOME/.claude/CLAUDE.md" ]] || ! grep -q "BEGIN HUB-AI-RULES" "$HOME/.claude/CLAUDE.md" 2>/dev/null; then
733
- color_warn "Hub AI bundle が ~/.claude に展開されていません (CLAUDE.md マーカー欠落)"
770
+ # 3. Hub AI bundle 展開を **CLI 別** に検証(manifest / CONFIG_MD マーカー /
771
+ # hooks の Hub SessionStart hook / 正規 .hub_token.json)。Codex 未インストールなら
772
+ # codex は検証しない(2026-07-15)。
773
+ local line cli_kind dir config_md hooks_file mver
774
+ while IFS= read -r line; do
775
+ [[ -z "$line" ]] && continue
776
+ IFS=":" read -r cli_kind dir config_md hooks_file <<< "$line"
777
+ if ! _hub_bundle_incomplete_cli "$dir" "$config_md" "$hooks_file"; then
778
+ mver="$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/$dir/scripts/manifest.json" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
779
+ color_ok "Hub AI bundle 展開済み ($cli_kind: ~/$dir/$config_md + scripts/manifest.json ${mver:+v$mver})"
734
780
  else
735
- color_warn "Hub AI bundle が半完了状態 (~/.claude/scripts/manifest.json 欠落)"
781
+ color_warn "Hub AI bundle が半完了状態 ($cli_kind: ~/$dir の manifest / $config_md マーカー / $hooks_file の SessionStart hook / .hub_token.json のいずれか欠落)"
782
+ color_warn " 手動再同期: hub-agent sync-bundle --cli-kind $cli_kind"
783
+ errors=$((errors + 1))
736
784
  fi
737
- color_warn " 手動再同期: hub-agent sync-bundle"
738
- errors=$((errors + 1))
739
- fi
785
+ done < <(_hub_target_clis)
740
786
 
741
787
  # 4. OS サービス起動 (Darwin 限定の確認。Linux は systemctl --user で別途)
742
788
  if [[ "$(uname)" == "Darwin" ]]; then
package/src/enroll.mjs CHANGED
@@ -82,15 +82,18 @@ export async function enroll(enrollmentToken, opts = {}) {
82
82
  // 無ければ codex のみ同期する。
83
83
  const bundleResults = {}
84
84
  if (!opts.skipBundle) {
85
- const { syncBundle } = await import("./hub-bundle.mjs")
86
- const { kickSetupHubAi } = await import("./main.mjs")
87
- // Hub backend が cli_kind='claude'/'codex' のみ対応するため他候補は無視する。
88
- // detectAvailableClis() が空を返す (PATH 検出失敗等) 場合も fail-soft で
89
- // 従来どおり claude 向け同期だけは必ず試みる。
90
- const detected = availableClis.filter((c) => c === "claude" || c === "codex")
91
- const targets = detected.length > 0 ? detected : ["claude"]
85
+ const { resolveSyncTargets, syncBundle } = await import("./hub-bundle.mjs")
86
+ // 検出された claude/codex を対象にする(Hub backend は cli_kind='claude'/'codex'
87
+ // のみ対応)。未検出は fail-soft で claude フォールバック。対象決定は
88
+ // resolveSyncTargets() に共通化し、enroll / sync-bundle CLI / self-heal で分岐させない。
89
+ const targets = resolveSyncTargets(availableClis)
92
90
  for (const cliKind of targets) {
93
91
  try {
92
+ // syncBundle が applyBundle → post_install(setup_hub_ai.py --silent) を **同期実行**
93
+ // するため、ここで別途 setup を kick する必要は無い(2026-07-15 に一本化。旧実装は
94
+ // enroll だけ別経路で detached kickSetupHubAi を叩き、sync-bundle / self-heal と
95
+ // 挙動が分岐していた)。CONFIG_MD / hooks / version / 旧 JWT の正規 token 移行まで
96
+ // syncBundle 完了時に揃う。
94
97
  bundleResults[cliKind] = await syncBundle({
95
98
  hubUrl,
96
99
  agentId: data.agent_id,
@@ -103,22 +106,6 @@ export async function enroll(enrollmentToken, opts = {}) {
103
106
  opts.logger?.warn?.({ cliKind, err: err.message }, "hub bundle sync failed (enroll still ok)")
104
107
  bundleResults[cliKind] = { error: err.message }
105
108
  }
106
-
107
- // bundle 同期で ~/.claude/scripts/setup_hub_ai.py (or ~/.codex/...) が
108
- // 配置された直後に kick する。設計穴: 初回 enroll では誰も
109
- // setup_hub_ai.py を実行しないため、`step_claude_md` が走らず
110
- // CLAUDE.md/AGENTS.md がいつまでも作成されない事故があった
111
- // (2026-06-17 toma 環境)。bundle 配信は scripts と
112
- // claude_md_fragment.md は含むが、CLAUDE.md/AGENTS.md と
113
- // settings.json/hooks.json は含まないため、誰かが setup_hub_ai を
114
- // 一度叩かないと self-bootstrap しない。enroll 直後に best-effort で
115
- // 1 回叩く。既存環境では step_claude_md がマーカー範囲を冪等更新
116
- // するだけで害なし。
117
- try {
118
- kickSetupHubAi({ logger: opts.logger, cliKind })
119
- } catch (err) {
120
- opts.logger?.warn?.({ cliKind, err: err.message }, "kickSetupHubAi from enroll failed (ignored)")
121
- }
122
109
  }
123
110
  }
124
111
 
@@ -180,8 +180,24 @@ export async function applyBundle({ bundle, logger, cliKind } = {}) {
180
180
  logger?.info({ dest, mode: mode.toString(8) }, "hub-bundle wrote file")
181
181
  }
182
182
 
183
- // JWT を保存 (~/.claude/scripts/.hub_jwt.json、または ~/.codex/scripts/.hub_jwt.json)
183
+ // token を保存する。役割分担(2026-07-15 整理、G):
184
+ // - 正規 `<cliDir>/.hub_token.json`(`{access, refresh}` のみ)= setup_hub_ai.py が
185
+ // step_token で読む SoT。これを書くことで sync/enroll 経由でも setup が token を
186
+ // 見つけて認証をスキップでき、Codex でも setup が完走する(旧実装は下記 legacy しか
187
+ // 書かず、Codex では setup が token を見つけられず --silent で中断していた)。
188
+ // - legacy `<cliDir>/scripts/.hub_jwt.json`(version 付き)= 後方互換で従来どおり残す。
189
+ // 既存端末の reader やロールバックのため削除しない。
190
+ // cliKind 別ディレクトリに書くため Claude/Codex の token が混在しない。
184
191
  if (bundle.token) {
192
+ const canonical = {}
193
+ if (typeof bundle.token.access === "string") canonical.access = bundle.token.access
194
+ if (typeof bundle.token.refresh === "string") canonical.refresh = bundle.token.refresh
195
+ if (canonical.access && canonical.refresh) {
196
+ const canonPath = path.join(resolveClaudeDir(kind), ".hub_token.json")
197
+ await fs.mkdir(path.dirname(canonPath), { recursive: true })
198
+ await fs.writeFile(canonPath, JSON.stringify(canonical), { mode: 0o600 })
199
+ written.push(canonPath)
200
+ }
185
201
  const tp = tokenPath(kind)
186
202
  await fs.mkdir(path.dirname(tp), { recursive: true })
187
203
  await fs.writeFile(
@@ -256,17 +272,44 @@ function extractMarkerBlock(text) {
256
272
  * post_install_commands を順次実行する。manifest に list で書く想定。
257
273
  * Hub backend が冪等なコマンドを記述する責任を持つ (例: chmod +x ...)。
258
274
  */
259
- export async function runPostInstall(bundle, { logger } = {}) {
275
+ export async function runPostInstall(bundle, { logger, isWindows } = {}) {
276
+ const win = isWindows ?? process.platform === "win32"
260
277
  const cmds = Array.isArray(bundle.post_install_commands)
261
278
  ? bundle.post_install_commands
262
279
  : []
263
280
  for (const cmd of cmds) {
264
- const argv = typeof cmd === "string" ? ["/bin/sh", "-c", cmd] : cmd
281
+ const argv = _postInstallArgv(cmd, win)
265
282
  if (!Array.isArray(argv) || argv.length === 0) continue
266
283
  await runOnce(argv, { logger })
267
284
  }
268
285
  }
269
286
 
287
+ /**
288
+ * post_install エントリを実行 argv に解決する。次の 3 形式を受ける:
289
+ * - 文字列: シェルコマンドとして `/bin/sh -c <str>`(Windows は `cmd /c <str>`)
290
+ * - 配列: そのまま argv(`spawn(bin, args)`)
291
+ * - オブジェクト `{cmd_unix, cmd_windows, description}`: OS に応じて cmd_unix /
292
+ * cmd_windows を選びシェルコマンドとして実行する
293
+ *
294
+ * ⚠️ 旧実装はオブジェクト形式を `typeof==="string"` でも `Array.isArray` でもないため
295
+ * 黙って `continue` していた(= backend manifest の `{cmd_unix,...}` 形式 post_install が
296
+ * 一切実行されず、sync-bundle 後の setup_hub_ai.py が走らなかった。2026-07-15 修正)。
297
+ * cli_kind 別パス(`~/.claude` / `~/.codex`)は backend が `{{CLI_DIR_NAME}}` を解決済みで
298
+ * 返すため、ここでは OS 選択だけを行い、不用意な追加 shell 展開はしない。
299
+ */
300
+ function _postInstallArgv(cmd, win) {
301
+ if (typeof cmd === "string") {
302
+ return win ? ["cmd", "/c", cmd] : ["/bin/sh", "-c", cmd]
303
+ }
304
+ if (Array.isArray(cmd)) return cmd
305
+ if (cmd && typeof cmd === "object") {
306
+ const line = win ? cmd.cmd_windows : cmd.cmd_unix
307
+ if (typeof line !== "string" || !line.trim()) return null
308
+ return win ? ["cmd", "/c", line] : ["/bin/sh", "-c", line]
309
+ }
310
+ return null
311
+ }
312
+
270
313
  function runOnce(argv, { logger } = {}) {
271
314
  return new Promise((resolve, reject) => {
272
315
  const [bin, ...args] = argv
@@ -453,9 +496,41 @@ async function resolveCliBin(binName) {
453
496
  }
454
497
 
455
498
  /**
456
- * fetch + apply + post_install + MCP server 自動登録をまとめて実行。
499
+ * 同期対象の CLI を決定する(共通ロジック、2026-07-15)。
500
+ *
501
+ * enroll / `sync-bundle` CLI / daemon self-heal で同じ規則を使い、経路ごとの分岐を無くす:
502
+ * - `detectAvailableClis()` の結果から backend 対応の `claude` / `codex` だけを採る
503
+ * - 明示 cliKind 指定時はそれ 1 件だけ(呼び出し側で `[cliKind]` を渡す)
504
+ * - 検出ゼロ(PATH 検出失敗等)は既存互換として `['claude']` にフォールバック
505
+ *
506
+ * @param {string[]} availableClis detectAvailableClis() の戻り
507
+ * @returns {Array<'claude'|'codex'>}
508
+ */
509
+ export function resolveSyncTargets(availableClis) {
510
+ const detected = (Array.isArray(availableClis) ? availableClis : []).filter(
511
+ (c) => c === "claude" || c === "codex",
512
+ )
513
+ return detected.length > 0 ? detected : ["claude"]
514
+ }
515
+
516
+ /**
517
+ * fetch + apply + post_install(setup) + MCP server 自動登録を **同期的に** まとめて実行。
457
518
  * cliKind='codex' で Codex 向け(`~/.codex/`、`codex mcp add`)に切り替わる
458
519
  * (Stream A2、2026-07-02。既定 'claude' は完全に従来どおり)。
520
+ *
521
+ * **責務と SoT(2026-07-15、二重取得の整理)**:
522
+ * - `applyBundle` = 最小ブートストラップ。files[] 配置・CONFIG_MD マーカー・token
523
+ * (正規 `.hub_token.json` + legacy `.hub_jwt.json`)を置き、**setup_hub_ai.py が存在し
524
+ * 実行できる状態**を作る。
525
+ * - `runPostInstall` は backend が cli_kind 別にレンダリングした post_install_commands
526
+ * (= `python3 <cliDir>/scripts/setup_hub_ai.py --silent`)を **await で同期実行** する。
527
+ * `setup_hub_ai.py` が **完全セットアップの SoT**:hooks の冪等マージ(settings.json /
528
+ * hooks.json)・installed_version 記録・旧 JWT の正規 token 移行・CONFIG_MD 生成を担う。
529
+ * その中で `step_install_files` が bundle を再取得して files を冪等に再検証する(= 2 回目の
530
+ * fetch。SoT の自己完結検証として許容。applyBundle の配置と重複しても冪等)。
531
+ * - よって **本関数が成功を返す時点で setup は完了しており、detached の待ち残りは無い**
532
+ * (手動 `hub-agent sync-bundle` は setup 完了まで同期的に待つ)。CLI 別パスは backend が
533
+ * `{{CLI_DIR_NAME}}` を解決して返すため、Codex 同期で `~/.claude` の setup を誤実行しない。
459
534
  */
460
535
  export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind = "claude" } = {}) {
461
536
  const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl, cliKind })
@@ -463,15 +538,60 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
463
538
  try {
464
539
  await runPostInstall(bundle, { logger })
465
540
  } catch (err) {
466
- logger?.warn({ err: err.message }, "post_install partially failed (continuing)")
541
+ // ⚠️ post_install(=setup_hub_ai.py) 失敗は **bundle 同期失敗として呼び出し元へ伝播** する
542
+ // (2026-07-15 差し戻し対応)。旧実装は warn で握りつぶして成功を返していたため、hooks 未登録 /
543
+ // CONFIG_MD 未生成 / installed_version 未記録 / token 検証失敗 / setup 内 ping 失敗 / self-reexec
544
+ // 失敗があっても「同期成功」と誤報告していた(syncBundle の「成功を返す時点で setup 完了済み」
545
+ // という docstring 契約と実装が不一致だった)。MCP 登録の fail-soft(下記)とは明確に分ける。
546
+ // 元の exit code / 原因を失わないよう cause と cliKind を付けて再 throw する。
547
+ logger?.error?.({ cliKind, err: err.message }, "post_install/setup failed")
548
+ const wrapped = new Error(`bundle setup failed (${cliKind}): ${err.message}`)
549
+ wrapped.cause = err
550
+ wrapped.cliKind = cliKind
551
+ throw wrapped
467
552
  }
553
+ // MCP 登録は fail-soft を維持(setup 完了後の付随処理。CLI が未インストール等でも同期自体は成功扱い)。
468
554
  let mcp = { added: [], skipped: [] }
469
555
  try {
470
556
  mcp = cliKind === "codex"
471
557
  ? await applyCodexMcpServers(bundle, { logger })
472
558
  : await applyMcpServers(bundle, { agentId, agentToken, logger })
473
559
  } catch (err) {
474
- logger?.warn({ err: err.message }, "applyMcpServers failed (continuing)")
560
+ logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")
475
561
  }
476
562
  return { version: bundle.version, ...result, mcp }
477
563
  }
564
+
565
+ /**
566
+ * 複数 CLI の syncBundle を順に実行し、**一方が失敗しても他方を試行**する純関数
567
+ * (bin/hub-agent.mjs の sync-bundle が process.exit する前段をテスト可能にするため分離。
568
+ * 2026-07-15)。
569
+ *
570
+ * @returns {{results: Record<string, any>, failures: string[]}}
571
+ * results[cliKind] は成功時 syncBundle の戻り、失敗時 `{ error, exitCode? }`。
572
+ * failures は失敗した cliKind の配列(1 件以上なら呼び出し側が exit 非 0 にする)。
573
+ */
574
+ export async function syncBundleTargets({
575
+ targets,
576
+ hubUrl,
577
+ agentId,
578
+ agentToken,
579
+ fetchImpl,
580
+ logger,
581
+ syncImpl,
582
+ } = {}) {
583
+ const sync = syncImpl || syncBundle
584
+ const results = {}
585
+ const failures = []
586
+ for (const cliKind of Array.isArray(targets) ? targets : []) {
587
+ try {
588
+ results[cliKind] = await sync({ hubUrl, agentId, agentToken, fetchImpl, cliKind, logger })
589
+ } catch (err) {
590
+ // exit code を message から救い出す(runOnce は "... (exit N): ..." を投げる)。
591
+ const m = /exit (\d+)/.exec(err?.cause?.message || err?.message || "")
592
+ results[cliKind] = { error: err.message, exitCode: m ? Number(m[1]) : null }
593
+ failures.push(cliKind)
594
+ }
595
+ }
596
+ return { results, failures }
597
+ }
package/src/main.mjs CHANGED
@@ -437,8 +437,9 @@ export function kickSetupHubAi({
437
437
  const setupScript = path.join(home, dirName, "scripts", "setup_hub_ai.py")
438
438
  const exists = existsImpl || existsSync
439
439
  if (!exists(setupScript)) {
440
+ // 全ログに cliKind を含める(Claude/Codex 2 行を識別できるように。2026-07-15 差し戻し対応)。
440
441
  logger?.info?.(
441
- { setupScript },
442
+ { cliKind, setupScript },
442
443
  "kickSetupHubAi: setup_hub_ai.py 不在 - skip",
443
444
  )
444
445
  return false
@@ -449,17 +450,52 @@ export function kickSetupHubAi({
449
450
  stdio: "ignore",
450
451
  })
451
452
  child?.on?.("error", (e) =>
452
- logger?.warn?.({ err: e?.message }, "kickSetupHubAi: spawn error"),
453
+ logger?.warn?.({ cliKind, setupScript, err: e?.message }, "kickSetupHubAi: spawn error"),
453
454
  )
454
455
  child?.unref?.()
455
- logger?.info?.({}, "kickSetupHubAi: setup_hub_ai --silent を起動 (hooks 同期)")
456
+ logger?.info?.(
457
+ { cliKind, setupScript },
458
+ "kickSetupHubAi: setup_hub_ai --silent を起動 (hooks 同期)",
459
+ )
456
460
  return true
457
461
  } catch (e) {
458
- logger?.warn?.({ err: e?.message }, "kickSetupHubAi: 失敗 (無視)")
462
+ logger?.warn?.({ cliKind, err: e?.message }, "kickSetupHubAi: 失敗 (無視)")
459
463
  return false
460
464
  }
461
465
  }
462
466
 
467
+ /**
468
+ * daemon 起動時 self-heal: 検出された Claude/Codex **双方**の setup_hub_ai.py を
469
+ * best-effort で kick する(2026-07-15)。旧実装は `kickSetupHubAi({logger})` = Claude 固定で、
470
+ * Codex が入っていても hooks.json/AGENTS.md/version が修復されなかった。
471
+ *
472
+ * - 対象決定は enroll / sync-bundle と共通の resolveSyncTargets に寄せる
473
+ * - 各 CLI を detached で best-effort 実行(既に整った環境では step_* が冪等で変更ゼロ)
474
+ * - kickSetupHubAi は例外を投げないため、一方の失敗で他方を止めない
475
+ * - ログには必ず cliKind を含める
476
+ */
477
+ export async function kickSetupHubAiForDetectedClis({
478
+ logger,
479
+ detectImpl,
480
+ homedir,
481
+ spawnImpl,
482
+ existsImpl,
483
+ } = {}) {
484
+ let targets = ["claude"]
485
+ try {
486
+ const detect = detectImpl || detectAvailableClis
487
+ const { resolveSyncTargets } = await import("./hub-bundle.mjs")
488
+ targets = resolveSyncTargets(await detect())
489
+ } catch (e) {
490
+ logger?.warn?.({ err: e?.message }, "kickSetupHubAiForDetectedClis: CLI 検出失敗 - claude のみ")
491
+ targets = ["claude"]
492
+ }
493
+ for (const cliKind of targets) {
494
+ kickSetupHubAi({ logger, cliKind, homedir, spawnImpl, existsImpl })
495
+ }
496
+ return targets
497
+ }
498
+
463
499
  export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
464
500
  const config = await readConfig()
465
501
  if (!config) {
@@ -511,7 +547,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
511
547
  // `hub-agent restart` だけで CLAUDE.md と hooks を自動修復できるようにする。
512
548
  // 既に整っている環境では step_* がマーカー範囲を冪等更新するだけで害なし
513
549
  // (detached + stdio ignore で daemon は妨げない)。WHY: 2026-06-17 toma 環境調査。
514
- kickSetupHubAi({ logger })
550
+ // 2026-07-15: Claude 固定から **検出された Claude/Codex 双方** の kick へ拡張。
551
+ await kickSetupHubAiForDetectedClis({ logger })
515
552
 
516
553
  // node-pty (Microsoft 公式) は macOS arm64 で PTY master fd を spawn ごとに leak し
517
554
  // (kill/destroy でも解放されず)、kern.tty.ptmx_max=511 到達で「新規ターミナルが