@cocorograph/hub-agent 0.5.26 → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/install.sh +153 -15
- package/src/pty-bridge.mjs +102 -1
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
|
|
22
22
|
set -euo pipefail
|
|
23
23
|
|
|
24
|
+
# brew の auto-update / hint 出力は初回 install で長時間化する原因の最大要因。
|
|
25
|
+
# 環境変数で抑止して 1 発完走率を上げる。最新 Formula が欲しいときは
|
|
26
|
+
# ユーザーが `brew update` を別途実行する前提。
|
|
27
|
+
export HOMEBREW_NO_AUTO_UPDATE=1
|
|
28
|
+
export HOMEBREW_NO_ENV_HINTS=1
|
|
29
|
+
export HOMEBREW_NO_INSTALL_CLEANUP=1
|
|
30
|
+
|
|
24
31
|
# Node.js のサポート範囲ポリシー(Active LTS のみ)。
|
|
25
32
|
# - 既存 node のメジャーが [MIN, MAX] に収まっていれば現状維持
|
|
26
33
|
# - 範囲外なら NODE_DEFAULT_BREW_FORMULA (最新 Active LTS) にアップ / ダウングレード
|
|
@@ -42,6 +49,42 @@ color_err() { printf "\033[1;31m✗ %s\033[0m\n" "$1" >&2; }
|
|
|
42
49
|
|
|
43
50
|
present() { command -v "$1" >/dev/null 2>&1; }
|
|
44
51
|
|
|
52
|
+
# step counter (main() で使う)。色は出すが詳細メッセージは関数内に任せる。
|
|
53
|
+
# STEP_TOTAL は main の冒頭で再設定する想定。
|
|
54
|
+
STEP_TOTAL=10
|
|
55
|
+
STEP_NUM=0
|
|
56
|
+
step_header() {
|
|
57
|
+
STEP_NUM=$((STEP_NUM + 1))
|
|
58
|
+
printf "\033[1;36m\n━━━ [%d/%d] %s ━━━\033[0m\n" "$STEP_NUM" "$STEP_TOTAL" "$1"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# 指定コマンドを最大 N 回まで指数 backoff で retry する。
|
|
62
|
+
# transient な network / brew / npm 失敗を耐える用途。
|
|
63
|
+
# 使い方: retry 3 brew install foo
|
|
64
|
+
# retry 3 npm install -g bar
|
|
65
|
+
#
|
|
66
|
+
# 重要: bash の `set -e` 下では `if cmd` パターンは cmd の戻り値で分岐するため、
|
|
67
|
+
# cmd 失敗時に script が exit しない (`set -e` の標準仕様)。
|
|
68
|
+
# retry 全体が失敗した時のみ呼び出し元が exit する。
|
|
69
|
+
retry() {
|
|
70
|
+
local max="$1"; shift
|
|
71
|
+
local i=1
|
|
72
|
+
local delay=2
|
|
73
|
+
while true; do
|
|
74
|
+
if "$@"; then
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
if (( i >= max )); then
|
|
78
|
+
color_err "コマンドが ${max} 回連続失敗: $*"
|
|
79
|
+
return 1
|
|
80
|
+
fi
|
|
81
|
+
color_warn "失敗 (${i}/${max}) → ${delay}s 後に再試行: $*"
|
|
82
|
+
sleep "$delay"
|
|
83
|
+
i=$((i + 1))
|
|
84
|
+
delay=$((delay * 2))
|
|
85
|
+
done
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
# 現在 PATH にある brew が、このユーザーで書き込み可能か判定する。
|
|
46
89
|
# 「brew はあるが Cellar が他ユーザー所有で書き込み不可」というケースを検知して
|
|
47
90
|
# user-local Homebrew にフォールバックするための判定関数。
|
|
@@ -89,7 +132,9 @@ ensure_brew() {
|
|
|
89
132
|
_install_user_local_brew
|
|
90
133
|
else
|
|
91
134
|
color_step "Homebrew をシステムインストール"
|
|
92
|
-
|
|
135
|
+
# Homebrew 公式 install.sh をダウンロードして実行。
|
|
136
|
+
# curl は GitHub raw のレート制限や transient 502 を retry でカバーする。
|
|
137
|
+
retry 3 bash -c '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
|
93
138
|
# Apple Silicon の brew はデフォルト PATH に入らないので追加
|
|
94
139
|
if [[ -d /opt/homebrew/bin ]]; then
|
|
95
140
|
export PATH="/opt/homebrew/bin:$PATH"
|
|
@@ -107,7 +152,9 @@ _install_user_local_brew() {
|
|
|
107
152
|
else
|
|
108
153
|
color_step "Homebrew をユーザーローカルインストール (\$HOME/homebrew)"
|
|
109
154
|
mkdir -p "$HOME/homebrew"
|
|
110
|
-
|
|
155
|
+
# GitHub tarball ダウンロード + tar 展開を 1 つの bash -c に包んで retry にかける。
|
|
156
|
+
# pipe 途中の curl 失敗を retry のスコープに収めるため。
|
|
157
|
+
retry 3 bash -c 'curl -fsSL https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C "$HOME/homebrew"'
|
|
111
158
|
fi
|
|
112
159
|
# PATH を user-local 優先で並べる(既存 /usr/local/bin/brew より先)
|
|
113
160
|
export PATH="$HOME/homebrew/bin:$PATH"
|
|
@@ -398,14 +445,15 @@ ensure_pkg() {
|
|
|
398
445
|
if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
|
|
399
446
|
color_step "$cmd をインストール"
|
|
400
447
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
401
|
-
brew install
|
|
448
|
+
# brew install は transient (network / hash mismatch / pour 失敗) を retry で吸収
|
|
449
|
+
retry 3 brew install "$brew_pkg"
|
|
402
450
|
elif present apt-get; then
|
|
403
|
-
sudo apt-get update -y
|
|
404
|
-
sudo apt-get install -y "$apt_pkg"
|
|
451
|
+
retry 3 sudo apt-get update -y
|
|
452
|
+
retry 3 sudo apt-get install -y "$apt_pkg"
|
|
405
453
|
elif present dnf; then
|
|
406
|
-
sudo dnf install -y "$apt_pkg"
|
|
454
|
+
retry 3 sudo dnf install -y "$apt_pkg"
|
|
407
455
|
elif present pacman; then
|
|
408
|
-
sudo pacman -S --noconfirm "$apt_pkg"
|
|
456
|
+
retry 3 sudo pacman -S --noconfirm "$apt_pkg"
|
|
409
457
|
else
|
|
410
458
|
color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$cmd を手動で install してください"
|
|
411
459
|
exit 1
|
|
@@ -417,7 +465,7 @@ ensure_pkg() {
|
|
|
417
465
|
_install_node_lts_brew() {
|
|
418
466
|
local formula="$NODE_DEFAULT_BREW_FORMULA"
|
|
419
467
|
color_step "$formula (Active LTS) を install"
|
|
420
|
-
brew install "$formula"
|
|
468
|
+
retry 3 brew install "$formula"
|
|
421
469
|
# 既存無印 node が link されていれば外す(v26 current 等を退かす)
|
|
422
470
|
if brew list node >/dev/null 2>&1; then
|
|
423
471
|
color_step "既存 'node' formula を unlink ($formula を優先するため)"
|
|
@@ -465,15 +513,19 @@ ensure_node_version() {
|
|
|
465
513
|
}
|
|
466
514
|
|
|
467
515
|
ensure_global_install() {
|
|
516
|
+
# @latest 明示 + --force で npm cache stale 起因の「同 version 判定 skip」を回避。
|
|
517
|
+
# 過去事例: 0.5.24 が既に入っているマシンで `npm i -g xxx` が 0.5.26 に更新
|
|
518
|
+
# しない事象があった (npm の "no changes needed" 誤発火)。--force でこの判定を
|
|
519
|
+
# 飛ばし、@latest で確実に最新版を解決する。
|
|
468
520
|
if present hub-agent; then
|
|
469
521
|
local cur
|
|
470
522
|
cur=$(hub-agent --version 2>/dev/null || echo "unknown")
|
|
471
523
|
color_step "hub-agent (現在 $cur) を最新版にアップデート"
|
|
472
|
-
npm install -g "$PACKAGE_NAME"
|
|
473
524
|
else
|
|
474
525
|
color_step "$PACKAGE_NAME を install"
|
|
475
|
-
npm install -g "$PACKAGE_NAME"
|
|
476
526
|
fi
|
|
527
|
+
retry 3 npm install -g "${PACKAGE_NAME}@latest" --force
|
|
528
|
+
hash -r 2>/dev/null || true
|
|
477
529
|
color_ok "hub-agent $(hub-agent --version)"
|
|
478
530
|
}
|
|
479
531
|
|
|
@@ -485,11 +537,14 @@ ensure_claude_code() {
|
|
|
485
537
|
local cur
|
|
486
538
|
cur=$(claude --version 2>/dev/null || echo "unknown")
|
|
487
539
|
color_step "Claude Code (現在 $cur) を最新版にアップデート"
|
|
488
|
-
|
|
540
|
+
# claude は Anthropic 配信。失敗しても既存版で継続 (warning のみ)。
|
|
541
|
+
retry 2 npm install -g "${CLAUDE_CODE_PACKAGE}@latest" --force \
|
|
542
|
+
|| color_warn "Claude Code upgrade に失敗 (既存版で継続)"
|
|
489
543
|
else
|
|
490
544
|
color_step "$CLAUDE_CODE_PACKAGE を install"
|
|
491
|
-
npm install -g "$CLAUDE_CODE_PACKAGE"
|
|
545
|
+
retry 3 npm install -g "${CLAUDE_CODE_PACKAGE}@latest" --force
|
|
492
546
|
fi
|
|
547
|
+
hash -r 2>/dev/null || true
|
|
493
548
|
if present claude; then
|
|
494
549
|
color_ok "claude $(claude --version 2>/dev/null || echo 'installed')"
|
|
495
550
|
else
|
|
@@ -503,20 +558,88 @@ do_enroll() {
|
|
|
503
558
|
color_warn " 手動で実行: hub-agent enroll <token> --hub-url ${HUB_AGENT_URL:-https://api.hub.cocorograph.com}"
|
|
504
559
|
return 0
|
|
505
560
|
fi
|
|
561
|
+
local hub_url="${HUB_AGENT_URL:-https://api.hub.cocorograph.com}"
|
|
562
|
+
# enroll は token が短命の可能性があるため retry は 1 回のみ。
|
|
563
|
+
# (transient network なら retry が効くが、token 期限切れだと回数を増やしても無駄)
|
|
506
564
|
if [[ -f "$HOME/.hub/agent.json" ]]; then
|
|
507
565
|
color_warn "~/.hub/agent.json が既にあります。--force で上書きします"
|
|
508
|
-
hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$
|
|
566
|
+
retry 2 hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$hub_url" --force
|
|
509
567
|
else
|
|
510
|
-
hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$
|
|
568
|
+
retry 2 hub-agent enroll "$HUB_AGENT_TOKEN" --hub-url "$hub_url"
|
|
569
|
+
fi
|
|
570
|
+
|
|
571
|
+
# enroll 内部の syncBundle 失敗は warning だけで握りつぶされる (enroll 自体は
|
|
572
|
+
# success 扱い) ため、ここで bundle 展開状況を verify する。
|
|
573
|
+
# CLAUDE.md の HUB-AI-RULES マーカーが入っていれば bundle 反映済みと判定。
|
|
574
|
+
if [[ ! -f "$HOME/.claude/CLAUDE.md" ]] \
|
|
575
|
+
|| ! grep -q "BEGIN HUB-AI-RULES" "$HOME/.claude/CLAUDE.md" 2>/dev/null; then
|
|
576
|
+
color_warn "Hub AI bundle が ~/.claude に展開されていない可能性。sync-bundle を再試行"
|
|
577
|
+
if ! retry 2 hub-agent sync-bundle; then
|
|
578
|
+
color_warn "hub-agent sync-bundle が継続失敗。あとで手動実行してください:"
|
|
579
|
+
color_warn " hub-agent sync-bundle"
|
|
580
|
+
fi
|
|
511
581
|
fi
|
|
512
582
|
}
|
|
513
583
|
|
|
514
584
|
do_install_service() {
|
|
515
585
|
color_step "OS サービスとして自動起動を登録"
|
|
516
|
-
|
|
586
|
+
# install-service は launchctl bootout/bootstrap の transient で初回失敗する
|
|
587
|
+
# ことがある (既存 unit の残骸 + 同名 bootstrap 競合 等)。retry でカバー。
|
|
588
|
+
retry 2 hub-agent install-service
|
|
517
589
|
color_ok "install-service 完了。ログ: ~/.hub/agent.log"
|
|
518
590
|
}
|
|
519
591
|
|
|
592
|
+
# セットアップ最終検証。
|
|
593
|
+
# 「online には見えるがバンドル未配信」「サービス起動失敗」等の半完了状態を
|
|
594
|
+
# 検知してユーザーに次の手を案内する。返り値は 0 (errors > 0 でも継続)。
|
|
595
|
+
verify_setup() {
|
|
596
|
+
local errors=0
|
|
597
|
+
|
|
598
|
+
# 1. hub-agent CLI
|
|
599
|
+
if present hub-agent; then
|
|
600
|
+
color_ok "hub-agent CLI: $(hub-agent --version 2>/dev/null || echo 'installed')"
|
|
601
|
+
else
|
|
602
|
+
color_err "hub-agent コマンドが PATH に見つかりません"
|
|
603
|
+
errors=$((errors + 1))
|
|
604
|
+
fi
|
|
605
|
+
|
|
606
|
+
# 2. Claude Code (任意なので warn のみ)
|
|
607
|
+
if present claude; then
|
|
608
|
+
color_ok "Claude Code: $(claude --version 2>/dev/null || echo 'installed')"
|
|
609
|
+
else
|
|
610
|
+
color_warn "Claude Code (claude) コマンドが見つかりません (cockpit から claude を呼ぶ場合は必要)"
|
|
611
|
+
fi
|
|
612
|
+
|
|
613
|
+
# 3. Hub AI bundle 展開
|
|
614
|
+
if [[ -f "$HOME/.claude/CLAUDE.md" ]] \
|
|
615
|
+
&& grep -q "BEGIN HUB-AI-RULES" "$HOME/.claude/CLAUDE.md" 2>/dev/null; then
|
|
616
|
+
color_ok "Hub AI bundle 展開済み (~/.claude/CLAUDE.md)"
|
|
617
|
+
else
|
|
618
|
+
color_warn "Hub AI bundle が ~/.claude に展開されていません"
|
|
619
|
+
color_warn " 手動再同期: hub-agent sync-bundle"
|
|
620
|
+
errors=$((errors + 1))
|
|
621
|
+
fi
|
|
622
|
+
|
|
623
|
+
# 4. OS サービス起動 (Darwin 限定の確認。Linux は systemctl --user で別途)
|
|
624
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
625
|
+
if launchctl list 2>/dev/null | grep -q "co.cocorograph.hub-agent"; then
|
|
626
|
+
color_ok "launchd service 起動中 (co.cocorograph.hub-agent)"
|
|
627
|
+
else
|
|
628
|
+
color_warn "launchd service が見つかりません"
|
|
629
|
+
color_warn " 手動起動: hub-agent install-service"
|
|
630
|
+
errors=$((errors + 1))
|
|
631
|
+
fi
|
|
632
|
+
fi
|
|
633
|
+
|
|
634
|
+
if (( errors > 0 )); then
|
|
635
|
+
color_warn ""
|
|
636
|
+
color_warn "${errors} 件の不整合を検出。Hub UI で agent が online でない場合は以下を試してください:"
|
|
637
|
+
color_warn " hub-agent restart # サービス再起動"
|
|
638
|
+
color_warn " hub-agent sync-bundle # bundle 再配信"
|
|
639
|
+
color_warn " tail -20 ~/.hub/agent.log # 詳細ログ"
|
|
640
|
+
fi
|
|
641
|
+
}
|
|
642
|
+
|
|
520
643
|
macos_perm_guidance() {
|
|
521
644
|
[[ "$(uname)" != "Darwin" ]] && return 0
|
|
522
645
|
# 現在 PATH 上の node 実体パスを案内に埋め込む(フルディスクアクセス登録時に役立つ)
|
|
@@ -544,19 +667,34 @@ EOF
|
|
|
544
667
|
|
|
545
668
|
main() {
|
|
546
669
|
color_step "hub-agent ワンライナーセットアップを開始"
|
|
670
|
+
STEP_TOTAL=10
|
|
671
|
+
STEP_NUM=0
|
|
672
|
+
|
|
673
|
+
step_header "Homebrew"
|
|
547
674
|
ensure_brew
|
|
675
|
+
step_header "tmux"
|
|
548
676
|
ensure_pkg tmux tmux tmux
|
|
677
|
+
step_header "Node.js (Active LTS)"
|
|
549
678
|
ensure_node_version
|
|
679
|
+
step_header "npm global prefix"
|
|
550
680
|
ensure_npm_user_prefix
|
|
551
681
|
# ensure_node_version 直後だと brew link でシェルの command hash がズレている
|
|
552
682
|
# 場合があるため、prefix 設定後にまとめて TLS pre-flight check + 自動 fallback
|
|
553
683
|
# を行う。失敗時はガイダンス付きで exit するので、後段の npm install で TLS
|
|
554
684
|
# エラーを再度浴びる経路はカットされる。
|
|
685
|
+
step_header "Node TLS pre-flight"
|
|
555
686
|
ensure_node_tls_works
|
|
687
|
+
step_header "hub-agent install"
|
|
556
688
|
ensure_global_install
|
|
689
|
+
step_header "Claude Code install"
|
|
557
690
|
ensure_claude_code
|
|
691
|
+
step_header "enroll + bundle 同期"
|
|
558
692
|
do_enroll
|
|
693
|
+
step_header "OS サービス化"
|
|
559
694
|
do_install_service
|
|
695
|
+
step_header "最終検証"
|
|
696
|
+
verify_setup
|
|
697
|
+
|
|
560
698
|
echo ""
|
|
561
699
|
color_ok "セットアップ完了。Hub UI で online 表示を確認してください"
|
|
562
700
|
echo " https://hub.cocorograph.com/user/cockpit/agents"
|
package/src/pty-bridge.mjs
CHANGED
|
@@ -28,6 +28,18 @@ const DEFAULT_ROWS = 32
|
|
|
28
28
|
// に見える (1 フレーム 16.7ms より短いので体感レイテンシは増えない)。
|
|
29
29
|
const DEFAULT_COALESCE_MS = 12
|
|
30
30
|
|
|
31
|
+
// orphan stream GC 設定。
|
|
32
|
+
// - DEFAULT_GC_INTERVAL_MS: GC スキャン周期 (60s)。短すぎても CPU 負担、長すぎても
|
|
33
|
+
// pty 枯渇に到達してから掃除されるまでに時間がかかる。1 分は妥当な balance。
|
|
34
|
+
// - DEFAULT_GC_STALE_MS: stream idle 判定の閾値 (10 min)。Cockpit window が
|
|
35
|
+
// 通常使用中は 5s 周期で session.state 等で touch されるので絶対に達しない。
|
|
36
|
+
// リロード時の切断 (秒〜十数秒) も無傷。何時間も放置された孤児だけ掃除される。
|
|
37
|
+
// ※ アクティブ stream が「10 分間まったく操作されない」= ユーザー席離れ等。
|
|
38
|
+
// その場合のみ kill されるが、tmux session 自体は別 process なので作業内容
|
|
39
|
+
// は無傷、リロードで再 attach できる。
|
|
40
|
+
const DEFAULT_GC_INTERVAL_MS = 60_000
|
|
41
|
+
const DEFAULT_GC_STALE_MS = 10 * 60_000
|
|
42
|
+
|
|
31
43
|
function resolveBin(name) {
|
|
32
44
|
try {
|
|
33
45
|
return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
|
|
@@ -53,7 +65,32 @@ export class PtyBridge extends EventEmitter {
|
|
|
53
65
|
* `/bin/sh -c "exec tmux attach -t <sessionName>"`
|
|
54
66
|
* @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
|
|
55
67
|
*/
|
|
56
|
-
constructor({
|
|
68
|
+
constructor({
|
|
69
|
+
ptyModule,
|
|
70
|
+
logger,
|
|
71
|
+
plugins = [],
|
|
72
|
+
defaultSpawnCommand,
|
|
73
|
+
coalesceMs,
|
|
74
|
+
/**
|
|
75
|
+
* 孤児 stream の GC 設定。
|
|
76
|
+
*
|
|
77
|
+
* 背景: 旧設計では WS reconnect / Cockpit window リロード / タブ閉じ等で
|
|
78
|
+
* `pty.detach` が agent に届かないケースがあり、pty fd が leak していた。
|
|
79
|
+
* macOS の `kern.tty.ptmx_max` は 511 個でその上限に到達すると新規
|
|
80
|
+
* posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
|
|
81
|
+
*
|
|
82
|
+
* 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
|
|
83
|
+
* のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
|
|
84
|
+
* 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
|
|
85
|
+
* pty.data / pty.resize で常に touch されるため絶対に kill されない**。
|
|
86
|
+
* リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
|
|
87
|
+
* 未満なので無傷。何時間も放置された孤児だけ掃除される。
|
|
88
|
+
*
|
|
89
|
+
* gcStaleMs を 0 以下にすると GC を無効化する (旧挙動)。テスト・特殊用途向け。
|
|
90
|
+
*/
|
|
91
|
+
gcIntervalMs,
|
|
92
|
+
gcStaleMs,
|
|
93
|
+
} = {}) {
|
|
57
94
|
super()
|
|
58
95
|
if (!ptyModule || typeof ptyModule.spawn !== "function") {
|
|
59
96
|
throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
|
|
@@ -73,6 +110,59 @@ export class PtyBridge extends EventEmitter {
|
|
|
73
110
|
this.streams = new Map()
|
|
74
111
|
/** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
|
|
75
112
|
this.coalesceState = new Map()
|
|
113
|
+
|
|
114
|
+
// orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
|
|
115
|
+
this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
116
|
+
this.gcStaleMs = gcStaleMs ?? DEFAULT_GC_STALE_MS
|
|
117
|
+
/** @type {Map<string, number>} stream_id → lastSeenAt (ms epoch) */
|
|
118
|
+
this.lastSeenAt = new Map()
|
|
119
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
120
|
+
this._gcTimer = null
|
|
121
|
+
if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
|
|
122
|
+
this._gcTimer = setInterval(() => this._gcStaleStreams(), this.gcIntervalMs)
|
|
123
|
+
// unref で Node の event loop を止めない (test 環境で hang しないため)。
|
|
124
|
+
if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 現在時刻を ms epoch で返す。テストで now を差し替えるため override 可能に。
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
_now() {
|
|
133
|
+
return Date.now()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* stream_id を「最近 browser からアクセスがあった」と record する。
|
|
138
|
+
* attach / write / resize の各 entry point から呼ぶ。
|
|
139
|
+
* @param {string} stream_id
|
|
140
|
+
*/
|
|
141
|
+
_touch(stream_id) {
|
|
142
|
+
this.lastSeenAt.set(stream_id, this._now())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `gcStaleMs` を超えて idle な stream を一括 detach する。
|
|
147
|
+
*
|
|
148
|
+
* `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
|
|
149
|
+
* @returns {string[]} kill した stream_id 一覧 (debug / test 用)
|
|
150
|
+
*/
|
|
151
|
+
_gcStaleStreams() {
|
|
152
|
+
if (!(this.gcStaleMs > 0)) return []
|
|
153
|
+
const cutoff = this._now() - this.gcStaleMs
|
|
154
|
+
const killed = []
|
|
155
|
+
for (const [stream_id, ts] of this.lastSeenAt) {
|
|
156
|
+
if (ts < cutoff && this.streams.has(stream_id)) {
|
|
157
|
+
this.logger?.warn(
|
|
158
|
+
{ stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
|
|
159
|
+
"pty GC: orphaned stream killed",
|
|
160
|
+
)
|
|
161
|
+
this.detach({ stream_id })
|
|
162
|
+
killed.push(stream_id)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return killed
|
|
76
166
|
}
|
|
77
167
|
|
|
78
168
|
/**
|
|
@@ -134,6 +224,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
134
224
|
}
|
|
135
225
|
|
|
136
226
|
this.streams.set(stream_id, pty)
|
|
227
|
+
this._touch(stream_id)
|
|
137
228
|
this.logger?.info(
|
|
138
229
|
{ stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
|
|
139
230
|
"pty attached",
|
|
@@ -164,6 +255,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
164
255
|
this._flushCoalesce(stream_id)
|
|
165
256
|
this.coalesceState.delete(stream_id)
|
|
166
257
|
this.streams.delete(stream_id)
|
|
258
|
+
this.lastSeenAt.delete(stream_id)
|
|
167
259
|
this.emit("exit", { stream_id, code: exitCode })
|
|
168
260
|
})
|
|
169
261
|
|
|
@@ -181,6 +273,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
181
273
|
this.logger?.warn({ stream_id }, "pty.write but stream missing")
|
|
182
274
|
return false
|
|
183
275
|
}
|
|
276
|
+
this._touch(stream_id)
|
|
184
277
|
pty.write(data)
|
|
185
278
|
return true
|
|
186
279
|
}
|
|
@@ -192,6 +285,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
192
285
|
this.logger?.warn({ stream_id }, "pty.resize but stream missing")
|
|
193
286
|
return false
|
|
194
287
|
}
|
|
288
|
+
this._touch(stream_id)
|
|
195
289
|
try {
|
|
196
290
|
pty.resize(cols, rows)
|
|
197
291
|
return true
|
|
@@ -208,6 +302,9 @@ export class PtyBridge extends EventEmitter {
|
|
|
208
302
|
// kill 前に残バッファを emit しておく (onExit にも flush はあるが、
|
|
209
303
|
// detach は browser 側都合なので最新を確実に届けたい)。
|
|
210
304
|
this._flushCoalesce(stream_id)
|
|
305
|
+
// GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
|
|
306
|
+
// 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
|
|
307
|
+
this.lastSeenAt.delete(stream_id)
|
|
211
308
|
try {
|
|
212
309
|
pty.kill()
|
|
213
310
|
} catch {
|
|
@@ -218,6 +315,10 @@ export class PtyBridge extends EventEmitter {
|
|
|
218
315
|
|
|
219
316
|
/** 全 pty を即時 kill (agent shutdown 用)。 */
|
|
220
317
|
shutdown() {
|
|
318
|
+
if (this._gcTimer) {
|
|
319
|
+
clearInterval(this._gcTimer)
|
|
320
|
+
this._gcTimer = null
|
|
321
|
+
}
|
|
221
322
|
for (const stream_id of Array.from(this.streams.keys())) {
|
|
222
323
|
this.detach({ stream_id })
|
|
223
324
|
}
|