@cocorograph/hub-agent 0.8.58 → 0.8.60

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/README.md CHANGED
@@ -17,8 +17,10 @@ curl -fsSL https://api.hub.cocorograph.com/api/cockpit/agents/install-script | b
17
17
 
18
18
  このスクリプトは以下を **全自動** で実行します:
19
19
  - Homebrew (macOS で無ければ install)
20
- - tmux + node 20+ (無ければ install)
20
+ - tmux + node (Active LTS) + python3 (無ければ install)
21
+ - GNU coreutils の `timeout` (補助コマンド。macOS では brew で導入して `gtimeout` への symlink を `~/.local/bin` に作成、Linux は存在確認のみで不在なら手動導入を案内。無くてもセットアップは続行)
21
22
  - `npm i -g @cocorograph/hub-agent`
23
+ - `npm i -g @anthropic-ai/claude-code` / `npm i -g @openai/codex` (Claude Code / Codex CLI。既存なら更新)
22
24
  - `hub-agent enroll <token>` (token は Hub session 経由で埋め込み済、5 分有効)
23
25
  - `hub-agent install-service` で OS サービス登録
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.58",
3
+ "version": "0.8.60",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -12,11 +12,14 @@
12
12
  # 1. macOS なら Homebrew (なければ install)
13
13
  # - 管理者権限が無い環境では自動で $HOME/homebrew にユーザーローカル
14
14
  # インストールに切り替わる (HUB_AGENT_USER_BREW=1 で強制も可)
15
- # 2. tmux + node (なければ install via brew / apt)
16
- # 3. npm i -g @cocorograph/hub-agent
17
- # 4. npm i -g @anthropic-ai/claude-code (既存があれば upgrade のみ)
18
- # 5. HUB_AGENT_TOKEN が設定されていれば hub-agent enroll を自動実行
19
- # 6. hub-agent install-service で OS サービス化
15
+ # 2. tmux + node + python3 (なければ install via brew / apt 等)
16
+ # 3. GNU coreutils の timeout (補助コマンド。macOS では brew で入れて gtimeout への
17
+ # symlink を ~/.local/bin に作成。Linux は存在確認のみで、不在なら手動導入を案内)
18
+ # 4. npm i -g @cocorograph/hub-agent
19
+ # 5. npm i -g @anthropic-ai/claude-code (既存があれば upgrade のみ)
20
+ # 6. npm i -g @openai/codex (常に install / upgrade)
21
+ # 7. HUB_AGENT_TOKEN が設定されていれば hub-agent enroll を自動実行
22
+ # 8. hub-agent install-service で OS サービス化
20
23
  # =============================================================================
21
24
 
22
25
  set -euo pipefail
@@ -41,6 +44,7 @@ NODE_DEFAULT_BREW_FORMULA="node@24"
41
44
 
42
45
  PACKAGE_NAME="@cocorograph/hub-agent"
43
46
  CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
47
+ CODEX_PACKAGE="@openai/codex"
44
48
 
45
49
  color_step() { printf "\033[1;34m==> %s\033[0m\n" "$1"; }
46
50
  color_ok() { printf "\033[1;32m✓ %s\033[0m\n" "$1"; }
@@ -223,6 +227,49 @@ persist_user_brew_shellenv() {
223
227
  done
224
228
  }
225
229
 
230
+ # snippet をマーカーで囲んで各 profile に冪等追記する。
231
+ # 使い方: persist_profile_snippet '<snippet>' '<label>' '<marker>' '<end_marker>' <profile>...
232
+ # 既に「snippet と完全一致する行」があれば skip する(部分一致だとコメントアウトされた
233
+ # `#export PATH=...` を既存扱いにして追記を飛ばす。マーカーの有無は見ない——マーカーだけ
234
+ # 残って snippet が欠けた部分書込みを再実行で補完できるようにするため)。
235
+ # 追記できない profile(root 所有・読み取り専用)は警告して次へ進み、最後に 1 を返す。
236
+ # 呼び出し側が失敗を致命にするか警告に留めるかを決める(set -e 下で裸で呼べば致命)。
237
+ persist_profile_snippet() {
238
+ local snippet="$1" label="$2" marker="$3" end_marker="$4"
239
+ shift 4
240
+ local target rc=0
241
+ for target in "$@"; do
242
+ if [[ -f "$target" ]] && grep -Fxq "$snippet" "$target"; then
243
+ continue
244
+ fi
245
+ if {
246
+ printf '\n%s\n' "$marker"
247
+ printf '%s\n' "$snippet"
248
+ printf '%s\n' "$end_marker"
249
+ } >> "$target" 2>/dev/null; then
250
+ color_ok "$target に $label を追記 (新規シェルから有効)"
251
+ else
252
+ color_warn "$target に $label を追記できません (書き込み権限を確認してください)"
253
+ rc=1
254
+ fi
255
+ done
256
+ return "$rc"
257
+ }
258
+
259
+ # bash の login profile を 1 つ選ぶ。bash は ~/.bash_profile → ~/.bash_login → ~/.profile の
260
+ # うち最初に存在するものだけを読むため、~/.profile しか無い環境で ~/.bash_profile を
261
+ # 新規作成すると ~/.profile が読まれなくなる。既存のものを優先し、無ければ ~/.bash_profile。
262
+ bash_login_profile() {
263
+ local f
264
+ for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
265
+ if [[ -f "$f" ]]; then
266
+ printf '%s\n' "$f"
267
+ return 0
268
+ fi
269
+ done
270
+ printf '%s\n' "$HOME/.bash_profile"
271
+ }
272
+
226
273
  # user-mode の場合、npm の global prefix を $HOME/.npm-global に切り替える。
227
274
  # brew が $HOME/homebrew にいるとき、その prefix への書き込み権限はあるものの、
228
275
  # 後で system 版 node / npm に切り替わる可能性も考えて user 領域に逃がしておく。
@@ -256,8 +303,6 @@ ensure_npm_user_prefix() {
256
303
  export PATH="$HOME/.npm-global/bin:$PATH"
257
304
 
258
305
  local snippet='export PATH="$HOME/.npm-global/bin:$PATH"'
259
- local marker="# >>> hub-agent: npm-global PATH (user-mode) >>>"
260
- local end_marker="# <<< hub-agent: npm-global PATH <<<"
261
306
  # 永続先は OS で出し分け。Linux の hub-agent は systemd --user 起動時や
262
307
  # `$SHELL -lic` 経由の env 注入で PATH を解決するため、login(.profile) と
263
308
  # interactive(.bashrc) の双方に通す。macOS は従来どおり zsh/bash の profile。
@@ -267,18 +312,9 @@ ensure_npm_user_prefix() {
267
312
  else
268
313
  targets=("$HOME/.bashrc" "$HOME/.profile")
269
314
  fi
270
- local target
271
- for target in "${targets[@]}"; do
272
- if [[ -f "$target" ]] && grep -Fq "$snippet" "$target"; then
273
- continue
274
- fi
275
- {
276
- printf '\n%s\n' "$marker"
277
- printf '%s\n' "$snippet"
278
- printf '%s\n' "$end_marker"
279
- } >> "$target"
280
- color_ok "$target に npm-global PATH を追記"
281
- done
315
+ persist_profile_snippet "$snippet" "npm-global PATH" \
316
+ "# >>> hub-agent: npm-global PATH (user-mode) >>>" "# <<< hub-agent: npm-global PATH <<<" \
317
+ "${targets[@]}"
282
318
  }
283
319
 
284
320
  # =============================================================================
@@ -458,28 +494,40 @@ ensure_node_tls_works() {
458
494
  exit 1
459
495
  }
460
496
 
461
- ensure_pkg() {
462
- local cmd="$1"
463
- local brew_pkg="$2"
464
- local apt_pkg="${3:-$brew_pkg}"
465
- if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
466
- color_step "$cmd をインストール"
497
+ # パッケージマネージャ分岐の共通処理。失敗は戻り値で返し、exit するかは呼び出し側が決める。
498
+ # 1 … 導入コマンドが失敗した / 2 … 対応するパッケージマネージャが無い
499
+ # ⚠️ display_cmd は復旧案内に出す「入れたい実行コマンド」。パッケージ名と一致しない
500
+ # ことがある (pacman の python パッケージ / 検証対象は python3 コマンド)。
501
+ _pm_install() {
502
+ local brew_pkg="$1"
503
+ local apt_pkg="$2"
504
+ local pacman_pkg="${3:-$apt_pkg}"
505
+ local display_cmd="${4:-$brew_pkg}"
467
506
  if [[ "$(uname)" == "Darwin" ]]; then
468
507
  # brew install は transient (network / hash mismatch / pour 失敗) を retry で吸収
469
- retry 3 brew install "$brew_pkg"
508
+ retry 3 brew install "$brew_pkg" || return 1
470
509
  elif present apt-get; then
471
- retry 3 sudo apt-get update -y
472
- retry 3 sudo apt-get install -y "$apt_pkg"
510
+ retry 3 sudo apt-get update -y || return 1
511
+ retry 3 sudo apt-get install -y "$apt_pkg" || return 1
473
512
  elif present dnf; then
474
- retry 3 sudo dnf install -y "$apt_pkg"
513
+ retry 3 sudo dnf install -y "$apt_pkg" || return 1
475
514
  elif present pacman; then
476
- retry 3 sudo pacman -S --noconfirm "$apt_pkg"
515
+ retry 3 sudo pacman -S --noconfirm "$pacman_pkg" || return 1
477
516
  else
478
- color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$cmd を手動で install してください"
479
- exit 1
517
+ color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$display_cmd を手動で install してください"
518
+ return 2
480
519
  fi
481
520
  }
482
521
 
522
+ ensure_pkg() {
523
+ local cmd="$1"
524
+ local brew_pkg="$2"
525
+ local apt_pkg="${3:-$brew_pkg}"
526
+ if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
527
+ color_step "$cmd をインストール"
528
+ _pm_install "$brew_pkg" "$apt_pkg" "" "$cmd" || exit 1
529
+ }
530
+
483
531
  # macOS で brew 経由で Active LTS の node (NODE_DEFAULT_BREW_FORMULA) を導入/切替する。
484
532
  # 既存の無印 node が link されていれば unlink してから新 formula を link --overwrite --force。
485
533
  _install_node_lts_brew() {
@@ -544,6 +592,111 @@ ensure_build_tools() {
544
592
  fi
545
593
  }
546
594
 
595
+ # Hub AI bundle のフック・helper と hub-agent bootstrap が使う python3 を保証する。
596
+ # macOS の /usr/bin/python3 は Xcode CLT 未導入時にスタブの場合があるため、
597
+ # command の存在ではなく実際の version コマンドが成功するかで判定する。
598
+ ensure_python3() {
599
+ if python3 --version >/dev/null 2>&1; then
600
+ color_ok "$(python3 --version 2>&1)"
601
+ return 0
602
+ fi
603
+
604
+ color_step "python3 をインストール"
605
+ # macOS で python3 が動かないのは、ほぼ Xcode CLT 未導入(/usr/bin/python3 がスタブ)。
606
+ # brew 経路が失敗したら、set -e で黙って落ちる前に実際の復旧手順を出す。
607
+ # pacman のパッケージ名は python3 でなく python。案内に出すコマンド名は python3。
608
+ if ! _pm_install python python3 python python3; then
609
+ if [[ "$(uname)" == "Darwin" ]]; then
610
+ color_err "python3 を導入できません。\`xcode-select --install\` を実行してから再実行してください"
611
+ fi
612
+ exit 1
613
+ fi
614
+ hash -r 2>/dev/null || true
615
+
616
+ if python3 --version >/dev/null 2>&1; then
617
+ color_ok "$(python3 --version 2>&1)"
618
+ else
619
+ color_err "python3 の導入後も実行できません"
620
+ exit 1
621
+ fi
622
+ }
623
+
624
+ # timeout を保証する(補助コマンド。AI セッションの Bash やスクリプトが使うが、
625
+ # hub-agent の常駐動作には必須ではないため、導入失敗は警告して継続する。
626
+ # 必須ランタイムの python3 とは契約を分けている)。
627
+ # macOS の coreutils は gtimeout 名で入るため、timeout だけを ~/.local/bin に公開する。
628
+ # coreutils の gnubin 全体を PATH に足すと sed / date 等まで GNU 版へ置き換わり、
629
+ # 既存スクリプトの挙動が変わるため採用しない。
630
+ _report_timeout() {
631
+ if present timeout; then
632
+ color_ok "timeout $(timeout --version 2>/dev/null </dev/null | head -1 || echo 'installed')"
633
+ else
634
+ color_warn "timeout コマンドが PATH に見つかりません (補助コマンドのため継続。新規シェルで再確認してください)"
635
+ fi
636
+ }
637
+
638
+ ensure_timeout() {
639
+ if [[ "$(uname)" != "Darwin" ]]; then
640
+ # Linux の timeout は coreutils (Debian の essential / busybox にも同梱) なので
641
+ # 通常は存在する。無い環境はインストーラが推測で入れず、警告に留める。
642
+ if ! present timeout; then
643
+ color_warn "timeout が見つかりません (coreutils を手動で導入してください。補助コマンドのため継続)"
644
+ fi
645
+ _report_timeout
646
+ return 0
647
+ fi
648
+
649
+ local local_bin="$HOME/.local/bin"
650
+ local link="$local_bin/timeout"
651
+ local found
652
+ found="$(command -v timeout 2>/dev/null || true)"
653
+ # このインストーラ管理の symlink 以外の timeout が既に見えていれば、それを尊重する
654
+ if [[ -n "$found" && "$found" != "$link" ]]; then
655
+ color_ok "timeout already installed ($found)"
656
+ return 0
657
+ fi
658
+
659
+ if [[ -e "$link" && ! -L "$link" ]]; then
660
+ # ユーザーが自前で置いた実体は上書きしない(PATH 永続化だけ揃える)
661
+ color_ok "$link は手動管理のため変更しません"
662
+ else
663
+ local gtimeout
664
+ gtimeout="$(command -v gtimeout 2>/dev/null || true)"
665
+ if [[ -z "$gtimeout" ]]; then
666
+ color_step "GNU coreutils (timeout) をインストール"
667
+ if ! retry 3 brew install coreutils; then
668
+ color_warn "coreutils の導入に失敗しました (timeout は補助コマンドのため継続)"
669
+ return 0
670
+ fi
671
+ hash -r 2>/dev/null || true
672
+ gtimeout="$(command -v gtimeout 2>/dev/null || true)"
673
+ fi
674
+ if [[ -z "$gtimeout" ]]; then
675
+ color_warn "gtimeout が PATH に見つかりません (timeout は補助コマンドのため継続)"
676
+ return 0
677
+ fi
678
+ if ! mkdir -p "$local_bin" 2>/dev/null || ! ln -sfn "$gtimeout" "$link" 2>/dev/null; then
679
+ color_warn "$link を作成できません (書き込み権限を確認してください。補助コマンドのため継続)"
680
+ return 0
681
+ fi
682
+ fi
683
+
684
+ # symlink の有無と PATH 永続化は別々に収束させる。再実行時に片方だけ欠けていても
685
+ # ここで補完される(早期 return で profile の修復を飛ばさない)。
686
+ # ~/.local/bin は PATH の「末尾」に足す。先頭に置くと pipx / pyenv 等が置いた
687
+ # ~/.local/bin/python3 や node のシムが brew / system のものを隠してしまう。
688
+ # timeout を見つけるだけなら末尾で足りる(別の timeout があれば上で return 済み)。
689
+ # PATH 代入で bash の command hash は全消去されるので、この後の hash -r は不要。
690
+ export PATH="$PATH:$local_bin"
691
+ # npm prefix と同じく zsh / bash 双方の login profile へ永続化する。
692
+ # 追記失敗(root 所有の profile 等)はこの補助コマンドの契約に合わせて警告に留める。
693
+ persist_profile_snippet 'export PATH="$PATH:$HOME/.local/bin"' "~/.local/bin PATH" \
694
+ "# >>> hub-agent: local-bin PATH (timeout) >>>" "# <<< hub-agent: local-bin PATH <<<" \
695
+ "$HOME/.zprofile" "$(bash_login_profile)" \
696
+ || color_warn "PATH の永続化に失敗した profile があります (timeout は補助コマンドのため継続)"
697
+ _report_timeout
698
+ }
699
+
547
700
  # Node.js のサポート範囲ポリシー判定。
548
701
  # - 未インストール → LTS を install
549
702
  # - [MIN, MAX] 範囲内 → 現状維持(ユーザーの環境を尊重)
@@ -599,29 +752,69 @@ ensure_global_install() {
599
752
  color_ok "hub-agent $(hub-agent --version)"
600
753
  }
601
754
 
602
- # Claude Code CLI を同梱でセットアップ。Cockpit 側から claude を呼び出すので、
603
- # 同じ npm-global prefix に入れておくとパス解決が一貫する。
604
- # 既存があれば upgrade のみ(破壊しない)。
605
- ensure_claude_code() {
606
- if present claude; then
755
+ # npm 配信の CLI を導入/upgrade する共通処理。
756
+ # required=1 … 新規導入の失敗を非 0 で呼び出し側へ返す。main から素の
757
+ # コマンドとして呼べば set -e でセットアップ全体が止まる
758
+ # (この関数自身は exit しないので、条件式から呼べば拾える)
759
+ # required=0 … 新規導入が失敗しても警告のみでセットアップを継続する
760
+ # 上記以外 … 契約違反として 2 を返す (タイプミスを optional へ倒さない)
761
+ # ⚠️ fail_hint は「導入できなかったとき何が使えなくなるか」を呼び出し側が渡す。
762
+ # この関数に CLI 固有の文言を書かないこと (次の CLI を足したとき嘘になる)。
763
+ _ensure_npm_cli() {
764
+ local cmd="$1"
765
+ local package="$2"
766
+ local label="$3"
767
+ local required="$4"
768
+ local fail_hint="${5:-}"
769
+ # ⚠️ required は 0/1 のみ受け付ける。"true" 等のタイプミスを optional 扱いへ
770
+ # 黙って倒すと、必須 CLI が入らないままセットアップが完了する。
771
+ case "$required" in
772
+ 0|1) ;;
773
+ *)
774
+ color_err "_ensure_npm_cli: required は 0/1 のみです (受け取った値: '${required}')"
775
+ return 2
776
+ ;;
777
+ esac
778
+ if present "$cmd"; then
607
779
  local cur
608
- cur=$(claude --version 2>/dev/null || echo "unknown")
609
- color_step "Claude Code (現在 $cur) を最新版にアップデート"
780
+ cur=$("$cmd" --version 2>/dev/null </dev/null || echo "unknown")
781
+ color_step "$label (現在 $cur) を最新版にアップデート"
610
782
  # claude は Anthropic 配信。失敗しても既存版で継続 (warning のみ)。
611
- retry 2 npm install -g "${CLAUDE_CODE_PACKAGE}@latest" --force \
612
- || color_warn "Claude Code upgrade に失敗 (既存版で継続)"
783
+ retry 2 npm install -g "${package}@latest" --force \
784
+ || color_warn "$label upgrade に失敗 (既存版で継続)"
613
785
  else
614
- color_step "$CLAUDE_CODE_PACKAGE を install"
615
- retry 3 npm install -g "${CLAUDE_CODE_PACKAGE}@latest" --force
786
+ color_step "$package を install"
787
+ if [[ "$required" == "1" ]]; then
788
+ # ⚠️ 明示的に return する。条件式 (`if f; then`) の中では関数内の errexit が
789
+ # 抑止されるため、暗黙の set -e に頼ると必須契約が呼び出し方で消える。
790
+ retry 3 npm install -g "${package}@latest" --force || return 1
791
+ elif ! retry 3 npm install -g "${package}@latest" --force; then
792
+ color_warn "$label の導入に失敗しました。${fail_hint:-セットアップは継続します}"
793
+ color_warn " 手動で実行: npm install -g ${package}@latest"
794
+ fi
616
795
  fi
617
796
  hash -r 2>/dev/null || true
618
- if present claude; then
619
- color_ok "claude $(claude --version 2>/dev/null || echo 'installed')"
797
+ if present "$cmd"; then
798
+ color_ok "$cmd $("$cmd" --version 2>/dev/null </dev/null || echo 'installed')"
620
799
  else
621
- color_warn "claude コマンドが PATH に見つかりません。新規シェルで再確認してください"
800
+ color_warn "$cmd コマンドが PATH に見つかりません。新規シェルで再確認してください"
622
801
  fi
623
802
  }
624
803
 
804
+ # Claude Code CLI を同梱でセットアップ。Cockpit 側から claude を呼び出すので、
805
+ # 同じ npm-global prefix に入れておくとパス解決が一貫する。
806
+ # 既存があれば upgrade のみ(破壊しない)。hub-agent のチャットに必須なので required=1。
807
+ ensure_claude_code() {
808
+ _ensure_npm_cli claude "$CLAUDE_CODE_PACKAGE" "Claude Code" 1
809
+ }
810
+
811
+ # Codex CLI を同梱でセットアップ。hub-agent 自体の動作には必須ではないため、
812
+ # 新規導入・upgrade とも失敗時は警告してセットアップを継続する。
813
+ ensure_codex() {
814
+ _ensure_npm_cli codex "$CODEX_PACKAGE" "Codex CLI" 0 \
815
+ "Codex チャット / 委譲 / 画像生成は使えません"
816
+ }
817
+
625
818
  do_enroll() {
626
819
  if [[ -z "${HUB_AGENT_TOKEN:-}" ]]; then
627
820
  color_warn "HUB_AGENT_TOKEN が未設定。enroll は skip します"
@@ -767,6 +960,24 @@ verify_setup() {
767
960
  color_warn "Claude Code (claude) コマンドが見つかりません (cockpit から claude を呼ぶ場合は必要)"
768
961
  fi
769
962
 
963
+ # Codex CLI (任意なので warn のみ)
964
+ if present codex; then
965
+ color_ok "Codex CLI: $(codex --version 2>/dev/null </dev/null || echo 'installed')"
966
+ else
967
+ color_warn "Codex CLI (codex) コマンドが見つかりません (Codex チャット / 委譲 / 画像生成には必要)"
968
+ fi
969
+
970
+ # python3 (Hub AI bundle と hub-agent bootstrap に必須)
971
+ if python3 --version >/dev/null 2>&1; then
972
+ color_ok "Python 3: $(python3 --version 2>&1)"
973
+ else
974
+ color_err "python3 コマンドが実行できません"
975
+ errors=$((errors + 1))
976
+ fi
977
+
978
+ # timeout は補助コマンド (ensure_timeout と同じ契約) なので warn のみ
979
+ _report_timeout
980
+
770
981
  # 3. Hub AI bundle 展開を **CLI 別** に検証(manifest / CONFIG_MD マーカー /
771
982
  # hooks の Hub SessionStart hook / 正規 .hub_token.json)。Codex 未インストールなら
772
983
  # codex は検証しない(2026-07-15)。
@@ -831,7 +1042,7 @@ EOF
831
1042
 
832
1043
  main() {
833
1044
  color_step "hub-agent ワンライナーセットアップを開始"
834
- STEP_TOTAL=11
1045
+ STEP_TOTAL=15
835
1046
  STEP_NUM=0
836
1047
 
837
1048
  step_header "Homebrew"
@@ -842,6 +1053,10 @@ main() {
842
1053
  ensure_node_version
843
1054
  step_header "Build tools (native addons)"
844
1055
  ensure_build_tools
1056
+ step_header "Python 3"
1057
+ ensure_python3
1058
+ step_header "GNU coreutils (timeout)"
1059
+ ensure_timeout
845
1060
  step_header "npm global prefix"
846
1061
  ensure_npm_user_prefix
847
1062
  # ensure_node_version 直後だと brew link でシェルの command hash がズレている
@@ -854,6 +1069,8 @@ main() {
854
1069
  ensure_global_install
855
1070
  step_header "Claude Code install"
856
1071
  ensure_claude_code
1072
+ step_header "Codex CLI install"
1073
+ ensure_codex
857
1074
  step_header "enroll + bundle 同期"
858
1075
  do_enroll
859
1076
  step_header "Hub AI bootstrap (CLAUDE.md / hooks 初期化)"
@@ -869,4 +1086,15 @@ main() {
869
1086
  macos_perm_guidance
870
1087
  }
871
1088
 
872
- main "$@"
1089
+ # このファイルが source されたか(実行されたか)を判定する。
1090
+ # - `bash -c "$(curl ...)"` / `curl ... | bash` : BASH_SOURCE が空 → 実行
1091
+ # - `bash install.sh` / `./install.sh` : BASH_SOURCE[0] == $0 → 実行
1092
+ # - `source install.sh`(test/install-sh.test.mjs): BASH_SOURCE[0] != $0 → source
1093
+ # 環境変数で切り替えると、変数が残ったシェルや CI でワンライナーが無言の no-op になるため使わない。
1094
+ _install_sh_is_sourced() {
1095
+ [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "$0" ]]
1096
+ }
1097
+
1098
+ if ! _install_sh_is_sourced; then
1099
+ main "$@"
1100
+ fi
@@ -169,10 +169,209 @@ async function getLastTurnAtMsCached(fileMap, filePath, mtimeMs, size) {
169
169
  // round2 minor 対応)。前回値も無ければ最後の手段として mtime。
170
170
  const extracted = await extractLastTurnAtMs(filePath, null)
171
171
  const lastTurnAtMs = extracted ?? entry?.lastTurnAtMs ?? mtimeMs
172
- fileMap.set(filePath, { mtimeMs, size, lastTurnAtMs })
172
+ fileMap.set(filePath, { ...entry, mtimeMs, size, lastTurnAtMs })
173
173
  return lastTurnAtMs
174
174
  }
175
175
 
176
+ const TITLE_SCAN_CHUNK_SIZE = 64 * 1024
177
+ const TITLE_FINGERPRINT_SIZE = 256
178
+ let titleScanBytesForTest = 0
179
+ let titleMaxCarryBytesForTest = 0
180
+ export function getTitleScanBytesForTest() {
181
+ return titleScanBytesForTest
182
+ }
183
+ export function resetTitleScanBytesForTest() {
184
+ titleScanBytesForTest = 0
185
+ }
186
+ export function getTitleMaxCarryBytesForTest() {
187
+ return titleMaxCarryBytesForTest
188
+ }
189
+ export function resetTitleMaxCarryBytesForTest() {
190
+ titleMaxCarryBytesForTest = 0
191
+ }
192
+
193
+ function takeTitleFromLine(line, entry) {
194
+ if (!line.includes('"custom-title"') && !line.includes('"ai-title"')) return
195
+ let obj
196
+ try {
197
+ obj = JSON.parse(line)
198
+ } catch {
199
+ return
200
+ }
201
+ if (obj?.type === "custom-title" && typeof obj.customTitle === "string") {
202
+ entry.customTitle = obj.customTitle
203
+ } else if (obj?.type === "ai-title" && typeof obj.aiTitle === "string") {
204
+ entry.aiTitle = obj.aiTitle
205
+ }
206
+ }
207
+
208
+ function titleFromEntry(entry) {
209
+ const customTitle = entry.customTitle?.trim().replace(/\s+/g, " ")
210
+ const aiTitle = entry.aiTitle?.trim().replace(/\s+/g, " ")
211
+ const title = customTitle || aiTitle
212
+ return title ? (MASK_ENABLED ? redactSecrets(title) : title) : undefined
213
+ }
214
+
215
+ /** jsonl の前回処理済み改行以降だけを読み、表示名を返す。
216
+ *
217
+ * 呼び出し側の stat (size / mtimeMs) は「走査が要るか」の判定にだけ使い、走査範囲と commit 値は
218
+ * open 後の fstat で取り直す (cross-review round2 F2: 古い stat を持った呼び出しが新しい走査の
219
+ * 後に到着すると、古い size まで再走査してキャッシュを巻き戻していた)。進行中の走査を待った
220
+ * 呼び出しは、その走査が fstat 時点の最新を commit しているので再走査せず commit 値を返す。
221
+ *
222
+ * 書き換え検知は「size 減少」と「scannedBytes 直前 256 バイトの fingerprint 不一致」で行う。
223
+ * ⚠️ 既知の限界 (round2 F1 で指摘・意図的に採らない): 末尾 256 バイトを保ったまま前方だけを
224
+ * 書き換える置換は検出できない。jsonl を書くのは Claude CLI だけで、title の変更は新しい
225
+ * custom-title / ai-title 行の追記 (last-wins) として現れ、既存行の置換は行わない。完全検知には
226
+ * 変更のたびに処理済み領域全体の再読込が要り、ハートビート経路の差分キャッシュが成立しなくなる。 */
227
+ async function extractTitleCached(fileMap, filePath, size, mtimeMs) {
228
+ let entry = fileMap.get(filePath)
229
+ if (!entry) {
230
+ entry = { scannedBytes: 0 }
231
+ fileMap.set(filePath, entry)
232
+ }
233
+
234
+ if (entry.titleScanPromise) {
235
+ // 進行中の走査は fstat 時点の最新状態を commit する。待った側の stat が新しくても古くても、
236
+ // ここで再走査すると (古い stat なら) 巻き戻し、(新しい stat なら) 二重走査になるだけなので
237
+ // commit 値をそのまま返す。走査後に追記された分は次回の listSessions で拾う。
238
+ await entry.titleScanPromise
239
+ return titleFromEntry(fileMap.get(filePath) || entry)
240
+ }
241
+
242
+ if (size === entry.scannedBytes && mtimeMs === entry.titleMtimeMs) {
243
+ return titleFromEntry(entry)
244
+ }
245
+
246
+ const scanPromise = (async () => {
247
+ const previousScannedBytes = typeof entry.scannedBytes === "number" ? entry.scannedBytes : 0
248
+ let scanFrom = previousScannedBytes
249
+ let customTitle = entry.customTitle
250
+ let aiTitle = entry.aiTitle
251
+ let fingerprint = Buffer.isBuffer(entry.titleFingerprint) ? entry.titleFingerprint : Buffer.alloc(0)
252
+ let skippingHugeLine = entry.titleSkippingHugeLine === true
253
+ let handle
254
+ try {
255
+ handle = await open(filePath, "r")
256
+ // 走査範囲と commit 値は呼び出し側の snapshot ではなく open した実体の fstat を使う。
257
+ const actualStat = await handle.stat()
258
+ size = actualStat.size
259
+ mtimeMs = actualStat.mtimeMs
260
+ let reset = size < previousScannedBytes
261
+ if (!reset && previousScannedBytes > 0) {
262
+ const fingerprintStart = Math.max(0, previousScannedBytes - TITLE_FINGERPRINT_SIZE)
263
+ const expectedLength = previousScannedBytes - fingerprintStart
264
+ const actual = Buffer.allocUnsafe(expectedLength)
265
+ const { bytesRead } = await handle.read(actual, 0, expectedLength, fingerprintStart)
266
+ titleScanBytesForTest += bytesRead
267
+ reset = bytesRead !== fingerprint.length
268
+ || !actual.subarray(0, bytesRead).equals(fingerprint)
269
+ }
270
+ if (reset) {
271
+ scanFrom = 0
272
+ customTitle = undefined
273
+ aiTitle = undefined
274
+ fingerprint = Buffer.alloc(0)
275
+ skippingHugeLine = false
276
+ }
277
+
278
+ let position = scanFrom
279
+ let scannedBytes = scanFrom
280
+ let fingerprintTail = fingerprint
281
+ let carry = Buffer.alloc(0)
282
+ while (position < size) {
283
+ const length = Math.min(TITLE_SCAN_CHUNK_SIZE, size - position)
284
+ const chunk = Buffer.allocUnsafe(length)
285
+ const { bytesRead } = await handle.read(chunk, 0, length, position)
286
+ if (bytesRead === 0) break
287
+ titleScanBytesForTest += bytesRead
288
+ position += bytesRead
289
+ let incoming = chunk.subarray(0, bytesRead)
290
+ if (skippingHugeLine) {
291
+ const newline = incoming.indexOf(NEWLINE_BYTE)
292
+ if (newline < 0) {
293
+ scannedBytes = position
294
+ fingerprintTail = Buffer.concat([fingerprintTail, incoming]).subarray(
295
+ Math.max(0, fingerprintTail.length + incoming.length - TITLE_FINGERPRINT_SIZE),
296
+ )
297
+ continue
298
+ }
299
+ const skippedThroughNewline = incoming.subarray(0, newline + 1)
300
+ fingerprintTail = Buffer.concat([fingerprintTail, skippedThroughNewline]).subarray(
301
+ Math.max(
302
+ 0,
303
+ fingerprintTail.length + skippedThroughNewline.length - TITLE_FINGERPRINT_SIZE,
304
+ ),
305
+ )
306
+ incoming = incoming.subarray(newline + 1)
307
+ skippingHugeLine = false
308
+ scannedBytes = position - incoming.length
309
+ }
310
+ const region = carry.length
311
+ ? Buffer.concat([carry, incoming])
312
+ : incoming
313
+ const lastNewline = region.lastIndexOf(0x0a)
314
+ if (lastNewline < 0) {
315
+ if (region.length > LAST_TURN_MAX_LINE) {
316
+ carry = Buffer.alloc(0)
317
+ skippingHugeLine = true
318
+ scannedBytes = position
319
+ fingerprintTail = region.subarray(Math.max(0, region.length - TITLE_FINGERPRINT_SIZE))
320
+ } else {
321
+ carry = region
322
+ titleMaxCarryBytesForTest = Math.max(titleMaxCarryBytesForTest, carry.length)
323
+ }
324
+ continue
325
+ }
326
+ const complete = region.subarray(0, lastNewline)
327
+ for (const lineBuf of splitLinesBuf(complete)) {
328
+ if (lineBuf.length > LAST_TURN_MAX_LINE) continue
329
+ const titleState = { customTitle, aiTitle }
330
+ takeTitleFromLine(lineBuf.toString("utf-8"), titleState)
331
+ customTitle = titleState.customTitle
332
+ aiTitle = titleState.aiTitle
333
+ }
334
+ carry = region.subarray(lastNewline + 1)
335
+ titleMaxCarryBytesForTest = Math.max(titleMaxCarryBytesForTest, carry.length)
336
+ scannedBytes = position - carry.length
337
+ const consumed = region.subarray(0, lastNewline + 1)
338
+ fingerprintTail = Buffer.concat([fingerprintTail, consumed]).subarray(
339
+ Math.max(0, fingerprintTail.length + consumed.length - TITLE_FINGERPRINT_SIZE),
340
+ )
341
+ }
342
+
343
+ const current = fileMap.get(filePath) || {}
344
+ fileMap.set(filePath, {
345
+ ...current,
346
+ customTitle,
347
+ aiTitle,
348
+ scannedBytes,
349
+ titleFingerprint: Buffer.from(fingerprintTail),
350
+ titleMtimeMs: mtimeMs,
351
+ titleSkippingHugeLine: skippingHugeLine,
352
+ titleScanPromise: scanPromise,
353
+ })
354
+ } catch {
355
+ // 個別ファイルの read エラーでは一覧自体を失敗させず、前回値を維持する。
356
+ } finally {
357
+ await handle?.close()
358
+ }
359
+ })()
360
+ entry.titleScanPromise = scanPromise
361
+ try {
362
+ await scanPromise
363
+ } finally {
364
+ const current = fileMap.get(filePath)
365
+ if (current?.titleScanPromise === scanPromise) delete current.titleScanPromise
366
+ }
367
+ return titleFromEntry(fileMap.get(filePath) || entry)
368
+ }
369
+
370
+ /** テスト用: 呼び出し側 snapshot を偽装して extractTitleCached を直接叩く (round2 F2 の逆順再現)。 */
371
+ export function extractTitleCachedForTest(filePath, size, mtimeMs) {
372
+ return extractTitleCached(getLastTurnDirMap(path.dirname(filePath)), filePath, size, mtimeMs)
373
+ }
374
+
176
375
  /** テスト用: last_turn_at_ms キャッシュを全消去する。 */
177
376
  export function clearLastTurnCacheForTest() {
178
377
  lastTurnDirCache.clear()
@@ -968,7 +1167,8 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
968
1167
  const sessions = []
969
1168
  for (const s of top) {
970
1169
  const preview = await extractPreview(s.file_path)
971
- sessions.push({ ...s, preview })
1170
+ const title = await extractTitleCached(fileMap, s.file_path, s.size_bytes, s.mtime)
1171
+ sessions.push({ ...s, preview, ...(title === undefined ? {} : { title }) })
972
1172
  }
973
1173
  return { sessions }
974
1174
  }
@@ -422,6 +422,9 @@ function isSessionRunningEvent(msg) {
422
422
  * 正しく終端として扱う (ガードが固着して _busy が永久 true になる = T06052 の再発を防ぐ安全弁)。 */
423
423
  const STALE_IDLE_GUARD_MS = 5000
424
424
 
425
+ /** per-message の title control request を待って InputQueue を閉じる上限。 */
426
+ const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
427
+
425
428
  /**
426
429
  * 1 stream に対応する Claude セッション (per-message query モデル)。
427
430
  */
@@ -498,6 +501,12 @@ export class ClaudeStreamSession {
498
501
 
499
502
  /** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
500
503
  this.sessionId = resumeSessionId || null
504
+ /** fresh resident query の初回プロンプト。query 起動時に一度だけ消費する。 */
505
+ this._pendingTitleSeed = null
506
+ /** 同じ session_id への title control request の二重送信を防ぐ。 */
507
+ this._titleRequestedFor = null
508
+ /** テストで timeout 経路を短縮可能にするが、実運用値は常に 15 秒。 */
509
+ this._sessionTitleCloseWaitMs = SESSION_TITLE_CLOSE_WAIT_MS
501
510
 
502
511
  /** browser 切断中フラグ。true でも query は中断せず継続する (絶対に落とさない)。 */
503
512
  this._detached = false
@@ -1356,6 +1365,7 @@ export class ClaudeStreamSession {
1356
1365
  // Phase 2 (major#1): resident fresh start は started を出さないため、注入なら source 付きで
1357
1366
  // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1358
1367
  this._maybeEmitInjectStarted(prompt)
1368
+ this._pendingTitleSeed = prompt
1359
1369
  this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
1360
1370
  return { accepted: true }
1361
1371
  }
@@ -1445,6 +1455,20 @@ export class ClaudeStreamSession {
1445
1455
  }
1446
1456
  // 直前ターンまでの session_id があれば resume チェーン
1447
1457
  if (this.sessionId) options.resume = this.sessionId
1458
+ const isFreshStart = !options.resume
1459
+ // title 待ちの後始末 (timer / abort listener / InputQueue close) は try の外に持ち、
1460
+ // 正常完走・init 未到達の異常終端・abort のどの経路でも finally で 1 回だけ冪等に行う
1461
+ // (cross-review F4: init 前に generator が return/reject すると timer と listener が残っていた)。
1462
+ const abortSignal = this._abortController.signal
1463
+ let titleWait = null
1464
+ const finishTitleWait = () => {
1465
+ if (!titleWait) return
1466
+ const { inputQueue, timer } = titleWait
1467
+ titleWait = null
1468
+ if (timer) clearTimeout(timer)
1469
+ abortSignal.removeEventListener("abort", finishTitleWait)
1470
+ inputQueue.close()
1471
+ }
1448
1472
 
1449
1473
  try {
1450
1474
  // 改修4 (A): canUseTool は streaming input (AsyncIterable prompt) でのみ確実に機能する。
@@ -1452,9 +1476,20 @@ export class ClaudeStreamSession {
1452
1476
  // resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
1453
1477
  const inputQueue = new InputQueue()
1454
1478
  inputQueue.push(toSDKUserMessage(prompt))
1455
- inputQueue.close()
1456
- const generator = this.sdk.query({ prompt: inputQueue, options })
1457
- for await (const msg of generator) {
1479
+ const q = this.sdk.query({ prompt: inputQueue, options })
1480
+ const shouldWaitForTitle =
1481
+ isFreshStart && this._canGenerateSessionTitle(q, prompt)
1482
+ if (shouldWaitForTitle) {
1483
+ // 入力を即 close すると title control request が "Query closed before response
1484
+ // received" で失敗する (実測) ため、title settle か上限時間まで close を遅らせる。
1485
+ const timer = setTimeout(finishTitleWait, this._sessionTitleCloseWaitMs)
1486
+ timer.unref?.()
1487
+ titleWait = { inputQueue, timer }
1488
+ abortSignal.addEventListener("abort", finishTitleWait, { once: true })
1489
+ } else {
1490
+ inputQueue.close()
1491
+ }
1492
+ for await (const msg of q) {
1458
1493
  if (
1459
1494
  msg?.type === "system" &&
1460
1495
  msg?.subtype === "init" &&
@@ -1463,6 +1498,9 @@ export class ClaudeStreamSession {
1463
1498
  this.sessionId = msg.session_id
1464
1499
  // session_id が確定/変化したら watch をその jsonl に張り替える
1465
1500
  this._ensureWatch()
1501
+ if (shouldWaitForTitle) {
1502
+ this._requestSessionTitle(q, prompt, msg.session_id).finally(finishTitleWait)
1503
+ }
1466
1504
  }
1467
1505
  // result イベントでも session_id が来ることがある (念のため拾う)
1468
1506
  if (msg?.type === "result") {
@@ -1490,6 +1528,8 @@ export class ClaudeStreamSession {
1490
1528
  }
1491
1529
  }
1492
1530
  } finally {
1531
+ // init 未到達で generator が終端した場合も title 待ちの timer / listener を残さない。
1532
+ finishTitleWait()
1493
1533
  this._abortController = null
1494
1534
  // 未解決 permission は閉じ、browser の孤児カードも撤去する
1495
1535
  this._denyAllPendingPermissions("turn ended")
@@ -1575,6 +1615,40 @@ export class ClaudeStreamSession {
1575
1615
  }
1576
1616
  }
1577
1617
 
1618
+ /** title 生成の静的条件。session_id 単位のガードは init 受信後に別途判定する。 */
1619
+ _canGenerateSessionTitle(q, seedText) {
1620
+ const trimmed = seedText.trim()
1621
+ return (
1622
+ process.env.HUB_AGENT_SESSION_TITLE !== "0" &&
1623
+ trimmed.length >= 10 &&
1624
+ !trimmed.startsWith("/") &&
1625
+ typeof q?.generateSessionTitle === "function"
1626
+ )
1627
+ }
1628
+
1629
+ /** title control request。失敗を通常ターンへ伝播させない。 */
1630
+ async _requestSessionTitle(q, seedText, sessionId) {
1631
+ if (
1632
+ this._titleRequestedFor === sessionId ||
1633
+ !this._canGenerateSessionTitle(q, seedText)
1634
+ ) {
1635
+ return
1636
+ }
1637
+ this._titleRequestedFor = sessionId
1638
+ try {
1639
+ const result = await q.generateSessionTitle(seedText, { persist: true })
1640
+ this.logger?.info(
1641
+ { title: result?.title, stream_id: this.stream_id },
1642
+ "session title generated",
1643
+ )
1644
+ } catch (err) {
1645
+ this.logger?.warn(
1646
+ { err: err.message, stream_id: this.stream_id },
1647
+ "session title generation failed",
1648
+ )
1649
+ }
1650
+ }
1651
+
1578
1652
  /** 改修3: pending キューの先頭を取り出して次の per-message ターンを発火する。
1579
1653
  * 各ターンの finally から呼ばれ、キューが空になるまで resume チェーンが続く。 */
1580
1654
  _drainPending() {
@@ -2042,6 +2116,8 @@ export class ClaudeStreamSession {
2042
2116
  // 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
2043
2117
  // query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
2044
2118
  if (this.sessionId) options.resume = this.sessionId
2119
+ const titleSeed = options.resume ? null : this._pendingTitleSeed
2120
+ this._pendingTitleSeed = null
2045
2121
  // 空 InputQueue 窓封鎖 (T06118): fresh start の初回ターンが ultracode を要求していれば、
2046
2122
  // per-message と同じく起動時 options.settings に焼き込む。こうすることで「query を空 queue
2047
2123
  // で先に起動 → applyFlagSettings を await → push」という空窓経路を踏まずに済む。初回だけ
@@ -2068,6 +2144,9 @@ export class ClaudeStreamSession {
2068
2144
  typeof msg.session_id === "string"
2069
2145
  ) {
2070
2146
  this.sessionId = msg.session_id
2147
+ if (titleSeed != null) {
2148
+ void this._requestSessionTitle(q, titleSeed, msg.session_id)
2149
+ }
2071
2150
  }
2072
2151
  if (msg?.type === "result") {
2073
2152
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id