@cocorograph/hub-agent 0.6.70 → 0.6.72
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 +20 -0
- package/package.json +1 -1
- package/scripts/install.ps1 +260 -0
- package/src/chat-signals.mjs +6 -1
- package/src/claude-stream-bridge.mjs +32 -2
- package/src/main.mjs +23 -0
- package/src/state.mjs +34 -1
package/README.md
CHANGED
|
@@ -22,6 +22,26 @@ curl -fsSL https://api.hub.cocorograph.com/api/cockpit/agents/install-script | b
|
|
|
22
22
|
- `hub-agent enroll <token>` (token は Hub session 経由で埋め込み済、5 分有効)
|
|
23
23
|
- `hub-agent install-service` で OS サービス登録
|
|
24
24
|
|
|
25
|
+
### Windows (WSL2 方式)
|
|
26
|
+
|
|
27
|
+
Windows では WSL2 (Ubuntu) の中で hub-agent を動かします。Cockpit Agents ページで
|
|
28
|
+
OS を Windows に切り替えると **管理者 PowerShell 用ワンライナー**が表示されるので、
|
|
29
|
+
**管理者として実行した PowerShell** にコピペします:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
$env:HUB_AGENT_TOKEN="<tok>"; $env:HUB_AGENT_URL="https://api.hub.cocorograph.com"; `
|
|
33
|
+
irm https://unpkg.com/@cocorograph/hub-agent@latest/scripts/install.ps1 | iex
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
このスクリプトは WSL2 (`Ubuntu-24.04`) を導入 (要再起動 1 回・再起動後に自動再開)、
|
|
37
|
+
Ubuntu 初回ユーザーを無人作成 (Windows と同名 / パスワードなし / NOPASSWD sudo)、
|
|
38
|
+
WSL 内で既存 `install.sh` を実行、Task Scheduler でログオン時の常駐起動を登録します。
|
|
39
|
+
|
|
40
|
+
> ⚠️ 作業リポジトリは **WSL 内 FS** (`/home/<user>/...`) に置いてください。Windows ドライブ
|
|
41
|
+
> (`/mnt/c/...`) はファイル監視 (inotify) が WSL 跨ぎで不安定です。
|
|
42
|
+
|
|
43
|
+
詳細・設計判断は `scripts/install.ps1` と `docs/windows-installer-design.md` を参照。
|
|
44
|
+
|
|
25
45
|
### 手動インストール (デバッグ用)
|
|
26
46
|
|
|
27
47
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# hub-agent Windows ワンライナーインストーラ (WSL2 方式)
|
|
3
|
+
#
|
|
4
|
+
# Hub UI が enrollment token + hub url を埋め込んだ PowerShell one-liner を表示し、
|
|
5
|
+
# ユーザーが「管理者 PowerShell」にコピペ実行する。bash 版 install.sh の Windows 版。
|
|
6
|
+
#
|
|
7
|
+
# 設計書: D00000_hub-agent/docs/windows-installer-design.md
|
|
8
|
+
# 方針判断: Hub ナレッジ `ナレッジ/Cockpit/Windows対応の設計判断` (id 7385)
|
|
9
|
+
#
|
|
10
|
+
# 単独使用 (token なし):
|
|
11
|
+
# irm https://unpkg.com/@cocorograph/hub-agent@latest/scripts/install.ps1 | iex
|
|
12
|
+
#
|
|
13
|
+
# Hub UI が生成する形 (token/url 埋め込み):
|
|
14
|
+
# $env:HUB_AGENT_TOKEN="<tok>"; $env:HUB_AGENT_URL="https://api.hub.cocorograph.com"; `
|
|
15
|
+
# irm https://unpkg.com/@cocorograph/hub-agent@latest/scripts/install.ps1 | iex
|
|
16
|
+
#
|
|
17
|
+
# 内容:
|
|
18
|
+
# フェーズ1: WSL2 導入 (管理者必須・再起動またぎを RunOnce で吸収)
|
|
19
|
+
# 再起動後 : Ubuntu 初回ユーザーを無人作成 (cloud-init → root install フォールバック)
|
|
20
|
+
# フェーズ2: WSL の中で既存 install.sh を呼ぶ (tmux/node/hub-agent/claude/enroll)
|
|
21
|
+
# 常駐化 : Task Scheduler のログオンタスクで distro を起こす (中の systemd が hub-agent を起動)
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
#Requires -Version 5.1
|
|
25
|
+
$ErrorActionPreference = "Stop"
|
|
26
|
+
|
|
27
|
+
# --- 定数 -------------------------------------------------------------------
|
|
28
|
+
# distro は LTS 固定 (将来の非互換回避)。launcher 実行ファイル名も distro に対応して固定。
|
|
29
|
+
$DISTRO = "Ubuntu-24.04"
|
|
30
|
+
$LAUNCHER = "ubuntu2404.exe"
|
|
31
|
+
$INSTALL_SH = "https://unpkg.com/@cocorograph/hub-agent@latest/scripts/install.sh"
|
|
32
|
+
$PS1_URL = "https://unpkg.com/@cocorograph/hub-agent@latest/scripts/install.ps1"
|
|
33
|
+
$RUNONCE_NAME = "HubAgentSetup"
|
|
34
|
+
$TASK_NAME = "HubAgentWSLBoot"
|
|
35
|
+
$DEFAULT_HUB = "https://api.hub.cocorograph.com"
|
|
36
|
+
|
|
37
|
+
# --- env からパラメータ取得 -------------------------------------------------
|
|
38
|
+
$Token = $env:HUB_AGENT_TOKEN
|
|
39
|
+
$HubUrl = if ($env:HUB_AGENT_URL) { $env:HUB_AGENT_URL } else { $DEFAULT_HUB }
|
|
40
|
+
$IsResume = ($env:HUB_AGENT_RESUME -eq "1") # 再起動後の RunOnce 自動再開フラグ
|
|
41
|
+
|
|
42
|
+
# --- カラーログ -------------------------------------------------------------
|
|
43
|
+
function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
44
|
+
function Write-Ok($m) { Write-Host "[OK] $m" -ForegroundColor Green }
|
|
45
|
+
function Write-Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
|
|
46
|
+
function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red }
|
|
47
|
+
|
|
48
|
+
# --- ヘルパー ---------------------------------------------------------------
|
|
49
|
+
function Test-IsAdmin {
|
|
50
|
+
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
51
|
+
$pr = New-Object Security.Principal.WindowsPrincipal($id)
|
|
52
|
+
return $pr.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# wsl -l -q は UTF-16LE + NUL 混じりで出すため、サニタイズして配列化する。
|
|
56
|
+
function Get-WslDistros {
|
|
57
|
+
try {
|
|
58
|
+
$raw = (wsl.exe -l -q) 2>$null
|
|
59
|
+
if (-not $raw) { return @() }
|
|
60
|
+
return ($raw -replace "`0", "") -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
|
61
|
+
} catch { return @() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Test-DistroExists { return (Get-WslDistros) -contains $DISTRO }
|
|
65
|
+
|
|
66
|
+
# WSL 機能そのもの (カーネル + プラットフォーム) が使える状態か。
|
|
67
|
+
function Test-WslReady {
|
|
68
|
+
try { wsl.exe --status *> $null; return ($LASTEXITCODE -eq 0) } catch { return $false }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# distro 内に対象ユーザーが存在するか (無人作成の冪等チェック)。
|
|
72
|
+
function Test-WslUserReady($user) {
|
|
73
|
+
if (-not $user) { return $false }
|
|
74
|
+
try { wsl.exe -d $DISTRO -u root -- id $user *> $null; return ($LASTEXITCODE -eq 0) } catch { return $false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Windows ユーザー名を Linux 命名規則にサニタイズ ([a-z_][a-z0-9_-]*)。
|
|
78
|
+
function Get-LinuxUserName {
|
|
79
|
+
$u = ($env:USERNAME).ToLower() -replace '[^a-z0-9_-]', ''
|
|
80
|
+
if ($u -eq '' -or $u -match '^[0-9]') { $u = "hub$u" }
|
|
81
|
+
return $u
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# RunOnce に「再起動後に自身を RESUME=1 で再実行」を仕込む。token/url を引き継ぐ。
|
|
85
|
+
function Set-ResumeOnReboot {
|
|
86
|
+
$cmd = "powershell -NoProfile -ExecutionPolicy Bypass -Command " +
|
|
87
|
+
"`"`$env:HUB_AGENT_TOKEN='$Token'; `$env:HUB_AGENT_URL='$HubUrl'; " +
|
|
88
|
+
"`$env:HUB_AGENT_RESUME='1'; irm $PS1_URL | iex`""
|
|
89
|
+
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce" `
|
|
90
|
+
-Name $RUNONCE_NAME -Value $cmd
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# 再起動はユーザーに委ねる (自動実行はデータ保全リスク)。RunOnce 登録済なので「あとで」でも自動再開。
|
|
94
|
+
function Prompt-RebootDecision {
|
|
95
|
+
Write-Host ""
|
|
96
|
+
Write-Step "WSL2 を導入しました。続行には Windows の再起動が必要です。"
|
|
97
|
+
Write-Host " 再起動後、セットアップは自動で続行します (手動操作は不要)。" -ForegroundColor Cyan
|
|
98
|
+
$ans = Read-Host "今すぐ再起動しますか? [Y=今すぐ / N=あとで自分で再起動]"
|
|
99
|
+
if ($ans -match '^[Yy]') {
|
|
100
|
+
Restart-Computer
|
|
101
|
+
} else {
|
|
102
|
+
Write-Warn "あとで Windows を再起動すると、セットアップが自動で続きます。"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# cloud-init 用 user-data を「初回起動前」に配置する。
|
|
107
|
+
# %USERPROFILE%\.cloud-init\<DistroName>.user-data に置くと初回起動で OOBE 無しに反映される。
|
|
108
|
+
function Write-CloudInit($user) {
|
|
109
|
+
$ciDir = Join-Path $env:USERPROFILE ".cloud-init"
|
|
110
|
+
New-Item -ItemType Directory -Force -Path $ciDir | Out-Null
|
|
111
|
+
$yaml = @"
|
|
112
|
+
#cloud-config
|
|
113
|
+
users:
|
|
114
|
+
- name: $user
|
|
115
|
+
groups: [sudo]
|
|
116
|
+
shell: /bin/bash
|
|
117
|
+
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
|
118
|
+
lock_passwd: true
|
|
119
|
+
write_files:
|
|
120
|
+
- path: /etc/wsl.conf
|
|
121
|
+
content: |
|
|
122
|
+
[user]
|
|
123
|
+
default=$user
|
|
124
|
+
[boot]
|
|
125
|
+
systemd=true
|
|
126
|
+
"@
|
|
127
|
+
# WSL/cloud-init は LF + UTF-8(BOM なし) を期待するため改行を正規化して書く。
|
|
128
|
+
$yaml = $yaml -replace "`r`n", "`n"
|
|
129
|
+
$path = Join-Path $ciDir "$DISTRO.user-data"
|
|
130
|
+
[System.IO.File]::WriteAllText($path, $yaml, (New-Object System.Text.UTF8Encoding($false)))
|
|
131
|
+
Write-Ok "cloud-init user-data を配置: $path"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# 方式A: cloud-init が完了するまで待つ。成功で $true。
|
|
135
|
+
function Invoke-CloudInitProvision($user) {
|
|
136
|
+
Write-Step "Ubuntu 初回ユーザーを無人作成 (cloud-init)"
|
|
137
|
+
try {
|
|
138
|
+
# 初回起動をトリガー。cloud-init が user-data を適用する。
|
|
139
|
+
wsl.exe -d $DISTRO -u root -- cloud-init status --wait *> $null
|
|
140
|
+
} catch {
|
|
141
|
+
Write-Warn "cloud-init status --wait に失敗 (cloud-init 非対応の可能性)"
|
|
142
|
+
return $false
|
|
143
|
+
}
|
|
144
|
+
return (Test-WslUserReady $user)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# 方式B (フォールバック): launcher install --root → useradd で無人作成。
|
|
148
|
+
function Invoke-RootInstallProvision($user) {
|
|
149
|
+
Write-Step "Ubuntu 初回ユーザーを無人作成 (root install フォールバック)"
|
|
150
|
+
try {
|
|
151
|
+
# OOBE 無しで root のみ初期化。既に登録済みなら無害。
|
|
152
|
+
& $LAUNCHER install --root *> $null
|
|
153
|
+
} catch {
|
|
154
|
+
Write-Warn "$LAUNCHER install --root に失敗 (続行して useradd を試行)"
|
|
155
|
+
}
|
|
156
|
+
$script = @"
|
|
157
|
+
set -e
|
|
158
|
+
useradd -m -s /bin/bash -G sudo '$user' 2>/dev/null || true
|
|
159
|
+
passwd -d '$user'
|
|
160
|
+
echo '$user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90-hub-$user
|
|
161
|
+
chmod 0440 /etc/sudoers.d/90-hub-$user
|
|
162
|
+
printf '[user]\ndefault=$user\n[boot]\nsystemd=true\n' > /etc/wsl.conf
|
|
163
|
+
"@
|
|
164
|
+
$script = $script -replace "`r`n", "`n"
|
|
165
|
+
wsl.exe -d $DISTRO -u root -- bash -lc $script
|
|
166
|
+
wsl.exe --shutdown
|
|
167
|
+
return (Test-WslUserReady $user)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# distro 内の /etc/wsl.conf に systemd=true が無ければ追記 (冪等)。
|
|
171
|
+
function Ensure-Systemd {
|
|
172
|
+
$has = (wsl.exe -d $DISTRO -u root -- bash -lc "grep -q 'systemd=true' /etc/wsl.conf 2>/dev/null && echo yes || echo no")
|
|
173
|
+
if (($has -replace "`0","").Trim() -ne "yes") {
|
|
174
|
+
wsl.exe -d $DISTRO -u root -- bash -lc "printf '[boot]\nsystemd=true\n' >> /etc/wsl.conf"
|
|
175
|
+
wsl.exe --shutdown
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Task Scheduler に「ログオン時に distro を起こす」タスクを登録 (Mac に無い新ピース)。
|
|
180
|
+
function Register-BootTask($user) {
|
|
181
|
+
Write-Step "Windows 起動時の常駐起動を登録 (Task Scheduler)"
|
|
182
|
+
$action = New-ScheduledTaskAction -Execute "wsl.exe" -Argument "-d $DISTRO -u $user -- /bin/true"
|
|
183
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
|
184
|
+
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
|
185
|
+
Register-ScheduledTask -TaskName $TASK_NAME -Action $action -Trigger $trigger -Settings $set `
|
|
186
|
+
-RunLevel Limited -Force | Out-Null
|
|
187
|
+
Write-Ok "ログオンタスク '$TASK_NAME' を登録しました"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# WSL の中で既存 install.sh を呼ぶ (tmux/node/hub-agent/claude/enroll/install-service)。
|
|
191
|
+
function Invoke-WslInstaller($user) {
|
|
192
|
+
Write-Step "WSL 内で hub-agent をセットアップ (既存 install.sh)"
|
|
193
|
+
$tok = if ($Token) { $Token } else { "" }
|
|
194
|
+
$inner = "curl -fsSL '$INSTALL_SH' | HUB_AGENT_TOKEN='$tok' HUB_AGENT_URL='$HubUrl' bash"
|
|
195
|
+
wsl.exe -d $DISTRO -u $user -- bash -lc $inner
|
|
196
|
+
if ($LASTEXITCODE -ne 0) { throw "WSL 内 install.sh が失敗しました (exit $LASTEXITCODE)" }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# =============================================================================
|
|
200
|
+
# メイン
|
|
201
|
+
# =============================================================================
|
|
202
|
+
function Main {
|
|
203
|
+
Write-Step "hub-agent Windows セットアップを開始 (WSL2 方式)"
|
|
204
|
+
|
|
205
|
+
if (-not (Test-IsAdmin)) {
|
|
206
|
+
Write-Err "管理者権限が必要です。PowerShell を『管理者として実行』で開き直してください。"
|
|
207
|
+
exit 1
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
$user = Get-LinuxUserName
|
|
211
|
+
|
|
212
|
+
# --- フェーズ1: WSL2 / distro 導入 (未導入時のみ。再起動をまたぐ) ---
|
|
213
|
+
if (-not $IsResume -and (-not (Test-WslReady) -or -not (Test-DistroExists))) {
|
|
214
|
+
Write-Step "WSL2 + $DISTRO を導入します"
|
|
215
|
+
Set-ResumeOnReboot # 再起動後に自動再開する仕掛けを先に仕込む
|
|
216
|
+
Write-CloudInit $user # 初回起動前に cloud-init を配置 (方式A の前提)
|
|
217
|
+
wsl.exe --install -d $DISTRO --no-launch
|
|
218
|
+
Prompt-RebootDecision # 再起動は Y/N でユーザーに委ねる
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# --- 再起動後 or 既に WSL 導入済み: ユーザー無人作成 ---
|
|
223
|
+
if (-not (Test-DistroExists)) {
|
|
224
|
+
# RESUME なのに distro が無い = wsl --install が再起動後に展開中のことがある。
|
|
225
|
+
Write-Warn "$DISTRO がまだ見つかりません。展開を待って初回起動を試みます..."
|
|
226
|
+
Write-CloudInit $user
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (-not (Test-WslUserReady $user)) {
|
|
230
|
+
# 方式A (cloud-init) → 失敗時 方式B (root install) へ自動フォールバック。
|
|
231
|
+
if (-not (Test-Path (Join-Path $env:USERPROFILE ".cloud-init\$DISTRO.user-data"))) {
|
|
232
|
+
Write-CloudInit $user
|
|
233
|
+
}
|
|
234
|
+
$ok = Invoke-CloudInitProvision $user
|
|
235
|
+
if (-not $ok) {
|
|
236
|
+
Write-Warn "cloud-init での作成を確認できませんでした。root install 方式に切り替えます。"
|
|
237
|
+
$ok = Invoke-RootInstallProvision $user
|
|
238
|
+
}
|
|
239
|
+
if (-not $ok) { throw "Ubuntu ユーザー '$user' の無人作成に失敗しました" }
|
|
240
|
+
Write-Ok "Ubuntu ユーザー '$user' を作成しました (パスワードなし / NOPASSWD sudo)"
|
|
241
|
+
} else {
|
|
242
|
+
Write-Ok "Ubuntu ユーザー '$user' は既に存在します"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
Ensure-Systemd
|
|
246
|
+
|
|
247
|
+
# --- フェーズ2: WSL 内ワンライナー ---
|
|
248
|
+
Invoke-WslInstaller $user
|
|
249
|
+
|
|
250
|
+
# --- 常駐化 ---
|
|
251
|
+
Register-BootTask $user
|
|
252
|
+
|
|
253
|
+
Write-Host ""
|
|
254
|
+
Write-Ok "セットアップ完了。Hub UI で online 表示を確認してください"
|
|
255
|
+
Write-Host " https://hub.cocorograph.com/user/cockpit/agents" -ForegroundColor Cyan
|
|
256
|
+
Write-Warn "注意: 作業リポジトリは WSL 内 (例: /home/$user/...) に置いてください。"
|
|
257
|
+
Write-Warn " Windows ドライブ (/mnt/c/...) はファイル監視が不安定です。"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
Main
|
package/src/chat-signals.mjs
CHANGED
|
@@ -28,7 +28,12 @@ const _byCwd = new Map()
|
|
|
28
28
|
|
|
29
29
|
// チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
|
|
30
30
|
// スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。
|
|
31
|
-
|
|
31
|
+
// 90s: SDK の result/abort を取りこぼしても status=processing がこの時間で解除される
|
|
32
|
+
// (RC-4 偽陽性 = 中断/異常終了で三点リーダーが固着する最長時間の短縮。旧 15 分)。正常な
|
|
33
|
+
// 生成中は SDK の assistant イベントが頻繁に updatedAtMs/statusAt を前進させるため途中で
|
|
34
|
+
// 切れない。abort/異常終了は claude-stream-bridge の finally が即 clearChatSignal するのが
|
|
35
|
+
// 一次対策で、本 TTL はその取りこぼし時のバックストップ。
|
|
36
|
+
const CHAT_SIGNAL_STALE_MS = 90 * 1000
|
|
32
37
|
|
|
33
38
|
const VALID_STATUS = new Set(["processing", "waiting", "idle"])
|
|
34
39
|
|
|
@@ -206,6 +206,7 @@ class ClaudeStreamSession {
|
|
|
206
206
|
onExit,
|
|
207
207
|
onError,
|
|
208
208
|
onReap,
|
|
209
|
+
onTurnSettled,
|
|
209
210
|
}) {
|
|
210
211
|
this.stream_id = stream_id
|
|
211
212
|
/** 多端末共有: このセッションを購読中の全端末 stream_id。CHAT_SHARED_ENABLED 時のみ
|
|
@@ -236,6 +237,10 @@ class ClaudeStreamSession {
|
|
|
236
237
|
this.onError = onError
|
|
237
238
|
/** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
|
|
238
239
|
this.onReap = onReap
|
|
240
|
+
/** RC-4: result を届けずにターンが終わった (abort/異常終了) ときに呼ぶコールバック。
|
|
241
|
+
* manager 経由で main.mjs がチャット信号の status=processing を waiting へ落とす
|
|
242
|
+
* (三点リーダー/ステータスドットが TTL まで固着するのを防ぐ)。 */
|
|
243
|
+
this.onTurnSettled = onTurnSettled
|
|
239
244
|
|
|
240
245
|
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
241
246
|
this.sessionId = resumeSessionId || null
|
|
@@ -632,6 +637,9 @@ class ClaudeStreamSession {
|
|
|
632
637
|
this._busy = true
|
|
633
638
|
this._abortController = new AbortController()
|
|
634
639
|
let aborted = false
|
|
640
|
+
// RC-4: このターンで SDK の result イベントが届いたか。届かずに終わった
|
|
641
|
+
// (abort / 異常終了) ときだけ onTurnSettled でチャット信号を waiting へ落とす。
|
|
642
|
+
let resultDelivered = false
|
|
635
643
|
|
|
636
644
|
const options = {
|
|
637
645
|
cwd: this.cwd,
|
|
@@ -677,8 +685,9 @@ class ClaudeStreamSession {
|
|
|
677
685
|
this._ensureWatch()
|
|
678
686
|
}
|
|
679
687
|
// result イベントでも session_id が来ることがある (念のため拾う)
|
|
680
|
-
if (msg?.type === "result"
|
|
681
|
-
|
|
688
|
+
if (msg?.type === "result") {
|
|
689
|
+
resultDelivered = true
|
|
690
|
+
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|
|
682
691
|
}
|
|
683
692
|
try {
|
|
684
693
|
this.onEvent?.(msg)
|
|
@@ -726,6 +735,18 @@ class ClaudeStreamSession {
|
|
|
726
735
|
if (aborted) {
|
|
727
736
|
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
728
737
|
}
|
|
738
|
+
// RC-4: result を届けずにターンが終わった (abort / 異常終了) 場合、チャット信号の
|
|
739
|
+
// status=processing が解除されず三点リーダー/ステータスドットが TTL まで固着する。
|
|
740
|
+
// ターン確定として onTurnSettled を呼び processing→waiting へ落とす。正常完了は
|
|
741
|
+
// result が既に waiting にしているので !resultDelivered のときだけ。_drainPending で
|
|
742
|
+
// 次ターン (processing) が始まる前に呼ぶこと (順序: ここ → reap/drain)。
|
|
743
|
+
if (!resultDelivered) {
|
|
744
|
+
try {
|
|
745
|
+
this.onTurnSettled?.()
|
|
746
|
+
} catch {
|
|
747
|
+
/* ignore */
|
|
748
|
+
}
|
|
749
|
+
}
|
|
729
750
|
// graceful detach: browser が切れている間にターンが完走したら、ここで遅延
|
|
730
751
|
// クローズする。manager 側で sessions Map から撤去 + exit を emit する。
|
|
731
752
|
if (this._reapAfterTurn && !this._closed) {
|
|
@@ -1305,6 +1326,15 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1305
1326
|
session_id: session.sessionId,
|
|
1306
1327
|
})
|
|
1307
1328
|
},
|
|
1329
|
+
onTurnSettled: () => {
|
|
1330
|
+
// RC-4: result 不在でターンが終わった (abort/異常終了)。main.mjs が cwd 一致の
|
|
1331
|
+
// チャット信号を processing→waiting へ落とす (固着解除)。
|
|
1332
|
+
this.emit("turnsettled", {
|
|
1333
|
+
stream_id: session.stream_id,
|
|
1334
|
+
session_id: session.sessionId,
|
|
1335
|
+
cwd: session.cwd,
|
|
1336
|
+
})
|
|
1337
|
+
},
|
|
1308
1338
|
})
|
|
1309
1339
|
this.sessions.set(stream_id, session)
|
|
1310
1340
|
this.logger?.info(
|
package/src/main.mjs
CHANGED
|
@@ -489,6 +489,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
489
489
|
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
490
490
|
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
491
491
|
})
|
|
492
|
+
claudeBridge.on("turnsettled", ({ cwd }) => {
|
|
493
|
+
// RC-4: result を届けずにターンが終わった (abort/異常終了)。チャット信号の
|
|
494
|
+
// status=processing を waiting に落として三点リーダー/ステータスドットの固着を防ぐ
|
|
495
|
+
// (正常完了は上の result ハンドラが既に waiting にしている)。
|
|
496
|
+
if (cwd) {
|
|
497
|
+
try {
|
|
498
|
+
recordChatActivity(cwd, { status: "waiting", inputPending: false })
|
|
499
|
+
} catch {
|
|
500
|
+
/* ignore */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
})
|
|
492
504
|
claudeBridge.on("error", ({ stream_id, session_id, error }) => {
|
|
493
505
|
client.send({ type: "claude.error", stream_id, session_id, error })
|
|
494
506
|
})
|
|
@@ -875,6 +887,16 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
875
887
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
876
888
|
let stopped = false
|
|
877
889
|
|
|
890
|
+
// RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
|
|
891
|
+
// 再 push する。差分送信のままだと、切断中に起きた status 遷移 (processing→stop 等) の push が
|
|
892
|
+
// 失われ、再接続後も frontend が古い processing に固着する (zombie WS の検知が遅れると最大数十秒)。
|
|
893
|
+
// client は (再)接続ごとに "open" を emit する。フロントの tmux.list_sessions poll でも回復するが、
|
|
894
|
+
// これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
|
|
895
|
+
const onReopen = () => {
|
|
896
|
+
lastByName.clear()
|
|
897
|
+
}
|
|
898
|
+
client.on("open", onReopen)
|
|
899
|
+
|
|
878
900
|
const tick = async () => {
|
|
879
901
|
if (stopped) return
|
|
880
902
|
try {
|
|
@@ -963,6 +985,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
963
985
|
stopped = true
|
|
964
986
|
clearInterval(ti)
|
|
965
987
|
clearTimeout(t0)
|
|
988
|
+
client.off?.("open", onReopen)
|
|
966
989
|
},
|
|
967
990
|
}
|
|
968
991
|
}
|
package/src/state.mjs
CHANGED
|
@@ -120,8 +120,38 @@ function stripAnsi(s) {
|
|
|
120
120
|
.replace(/\x1b[()][AB012]/g, "")
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* capturePane 結果の末尾フッター領域 (既定 8 行) を返す。claude TUI の作業スピナー /
|
|
125
|
+
* 中断フッターはペイン最下部に固定描画されるため、本文中に同じ語が出ても誤検出しない
|
|
126
|
+
* よう、作業スピナーの補助判定はこの領域に限定する。
|
|
127
|
+
*/
|
|
128
|
+
function footerRegion(text, lines = 8) {
|
|
129
|
+
if (!text) return ""
|
|
130
|
+
const rows = text.split("\n")
|
|
131
|
+
return rows.length <= lines ? text : rows.slice(-lines).join("\n")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 作業スピナーのフッター行を検出する。claude TUI の生成中フッターは
|
|
136
|
+
* "✻ Cogitating… (12s · ↑ 1.2k tokens · esc to interrupt)" のように
|
|
137
|
+
* 「経過秒」と「トークンカウンタ」が同一行に同居する。idle/waiting の権限バナーや
|
|
138
|
+
* 通常本文には出ないシグネチャなので、中断フッター文言 ("esc to interrupt") が
|
|
139
|
+
* 未描画 (ターン序盤) / locale 差 / ツール実行中などで取りこぼされる区間でも
|
|
140
|
+
* 生成中を拾える。誤検出を抑えるためフッター領域 + 同一行同居を必須にする。
|
|
141
|
+
*/
|
|
142
|
+
function detectWorkingSpinner(text) {
|
|
143
|
+
const footer = footerRegion(text)
|
|
144
|
+
for (const line of footer.split("\n")) {
|
|
145
|
+
if (/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)) return true
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
export function detectStatusFromText(text) {
|
|
151
|
+
// 主シグナル: 中断フッター (従来どおり全体一致。回帰防止のため範囲を狭めない)。
|
|
124
152
|
if (/esc to interrupt/i.test(text)) return "processing"
|
|
153
|
+
// 補助シグナル: 作業スピナーの「経過秒 + トークンカウンタ」(フッター領域・同一行限定)。
|
|
154
|
+
if (detectWorkingSpinner(text)) return "processing"
|
|
125
155
|
if (/❯\s/.test(text) || /^>\s/m.test(text)) return "waiting"
|
|
126
156
|
return "idle"
|
|
127
157
|
}
|
|
@@ -276,8 +306,11 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
276
306
|
"-p",
|
|
277
307
|
"-t",
|
|
278
308
|
sessionName,
|
|
309
|
+
// 末尾 50 行を取得 (旧 30 行)。copy-mode スクロールや背の高いペインで作業/中断
|
|
310
|
+
// フッターが取得窓外に出て生成中を取りこぼす偽陰性を減らす。status 判定の作業
|
|
311
|
+
// スピナー検出はこのうち末尾フッター領域 (footerRegion) に限定する。
|
|
279
312
|
"-S",
|
|
280
|
-
"-
|
|
313
|
+
"-50",
|
|
281
314
|
"-E",
|
|
282
315
|
"-",
|
|
283
316
|
])
|