@cocorograph/hub-agent 0.6.98 → 0.7.0
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/src/main.mjs +53 -15
- package/src/state.mjs +69 -0
- package/src/tmux.mjs +23 -0
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
detectPermissionModeFromText,
|
|
39
39
|
detectSessionState,
|
|
40
40
|
listSessionStates,
|
|
41
|
+
StallTracker,
|
|
41
42
|
} from "./state.mjs"
|
|
42
43
|
import {
|
|
43
44
|
DEFAULT_PROFILE_ID,
|
|
@@ -67,6 +68,7 @@ import {
|
|
|
67
68
|
listWorktreeNameHistory,
|
|
68
69
|
listWorktreeStubs,
|
|
69
70
|
rebindClaudeSession,
|
|
71
|
+
shouldSkipRebindRespawn,
|
|
70
72
|
recoverTuiInput,
|
|
71
73
|
removeWorktree as removeWorktreeDir,
|
|
72
74
|
resumeWithMessage,
|
|
@@ -592,6 +594,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
592
594
|
// 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
|
|
593
595
|
// Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
|
|
594
596
|
const readinessTracker = new ReadinessTracker()
|
|
597
|
+
// claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
|
|
598
|
+
// respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
|
|
599
|
+
ctx.readinessTracker = readinessTracker
|
|
595
600
|
const stateLoop = startStateLoop({
|
|
596
601
|
client,
|
|
597
602
|
plugins,
|
|
@@ -1050,8 +1055,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1050
1055
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
1051
1056
|
// (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
|
|
1052
1057
|
const outputFlowByName = new Map()
|
|
1053
|
-
// 権威的 stall
|
|
1054
|
-
|
|
1058
|
+
// 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
|
|
1059
|
+
// 無進捗を判定する。旧 lastProgressAtByName + armed ゲートの「フック依存 + cap 140s で消える」死角を解消。
|
|
1060
|
+
const stallTracker = new StallTracker({ stallWarnMs: STALL_WARN_MS })
|
|
1055
1061
|
let stopped = false
|
|
1056
1062
|
|
|
1057
1063
|
// RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
|
|
@@ -1138,20 +1144,23 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1138
1144
|
outputActive = Date.now() - changedAt < OUTPUT_ACTIVE_MS
|
|
1139
1145
|
}
|
|
1140
1146
|
const procBusy = childBusy || outputActive
|
|
1141
|
-
// 権威的 stall
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1147
|
+
// 権威的 stall 判定 (StallTracker, arm 非依存・cap 非依存)。armed (バンドルフックが効く
|
|
1148
|
+
// セッション) は OR で常に turn 開扱い (従来挙動の上位互換)。フック未発火/旧世代hookセッション
|
|
1149
|
+
// でも観測した processing で turnOpen を立て、frozen スピナー (spinner_present) を維持して proc 子も
|
|
1150
|
+
// 出力も止まったハングを拾う。proc 内省の cap(140s) disarm に依存しないので 140s 以後も消えない。
|
|
1151
|
+
// 旧実装の「armed 依存 + cap で消える」二重死角を解消。frontend はこれを応答停止バナーの正本に使う。
|
|
1145
1152
|
const armed = readinessTracker
|
|
1146
1153
|
? readinessTracker.isArmed(s.session_name)
|
|
1147
1154
|
: false
|
|
1148
1155
|
const nowMs = Date.now()
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1156
|
+
const { stalled } = stallTracker.observe(s.session_name, {
|
|
1157
|
+
armed,
|
|
1158
|
+
processing: status === "processing",
|
|
1159
|
+
procBusy,
|
|
1160
|
+
spinnerPresent: s.spinner_present === true,
|
|
1161
|
+
stable,
|
|
1162
|
+
now: nowMs,
|
|
1163
|
+
})
|
|
1155
1164
|
const prev = lastByName.get(s.session_name)
|
|
1156
1165
|
if (
|
|
1157
1166
|
!prev ||
|
|
@@ -1957,12 +1966,16 @@ async function dispatch(msg, ctx) {
|
|
|
1957
1966
|
// resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
|
|
1958
1967
|
// 生成され、frontend は回転検知 / sessions 応答で拾う。
|
|
1959
1968
|
const fresh = msg.fresh === true
|
|
1960
|
-
// 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)
|
|
1969
|
+
// 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。あわせて
|
|
1970
|
+
// 現在動いているセッション (= cwd の最新 jsonl = newestId) も解決する。生成中ガードで
|
|
1971
|
+
// 「再接続/remount による同一セッションへの再 bind」か「別 session への明示 switch」かを
|
|
1972
|
+
// 区別するのに使う (前者は respawn を抑止して生成中 claude を温存する)。
|
|
1961
1973
|
let targetId =
|
|
1962
1974
|
typeof msg.session_id === "string" && msg.session_id
|
|
1963
1975
|
? msg.session_id
|
|
1964
1976
|
: null
|
|
1965
|
-
|
|
1977
|
+
let newestId = null
|
|
1978
|
+
if (!fresh && cwd) {
|
|
1966
1979
|
const projectsRoot = await getActiveProjectsRoot()
|
|
1967
1980
|
const { sessions } = await listSessions({
|
|
1968
1981
|
cwd,
|
|
@@ -1970,7 +1983,20 @@ async function dispatch(msg, ctx) {
|
|
|
1970
1983
|
limit: 1,
|
|
1971
1984
|
logger,
|
|
1972
1985
|
})
|
|
1973
|
-
|
|
1986
|
+
newestId = sessions?.[0]?.session_id || null
|
|
1987
|
+
if (!targetId) targetId = newestId
|
|
1988
|
+
}
|
|
1989
|
+
// 生成中ガード (謎停止対策): respawn-pane -k は生成中 claude を強制 kill し in-flight 応答を失う。
|
|
1990
|
+
// hub-agent 再起動直後は tuiReboundSessions が空で冪等ガードが効かず、ブラウザ再接続の bind が
|
|
1991
|
+
// 生成中 claude を kill する。armed (ターン進行中) か pane=processing なら生成中とみなす。
|
|
1992
|
+
let generating = ctx.readinessTracker?.isArmed?.(sessionName) === true
|
|
1993
|
+
if (!generating && sessionName) {
|
|
1994
|
+
try {
|
|
1995
|
+
const snap = await detectSessionState(sessionName, {})
|
|
1996
|
+
if (snap?.status === "processing") generating = true
|
|
1997
|
+
} catch {
|
|
1998
|
+
// capture 失敗時は据え置き (生成中とみなさない = 従来挙動)
|
|
1999
|
+
}
|
|
1974
2000
|
}
|
|
1975
2001
|
// 2) SDK を停止 (cwd 全体 + 対象 id)。
|
|
1976
2002
|
let stoppedSdk = 0
|
|
@@ -1997,6 +2023,18 @@ async function dispatch(msg, ctx) {
|
|
|
1997
2023
|
if (bindKey && sessionName && (fresh || targetId)) {
|
|
1998
2024
|
if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
|
|
1999
2025
|
rebind = { ok: true, skipped: true }
|
|
2026
|
+
} else if (
|
|
2027
|
+
shouldSkipRebindRespawn({ generating, fresh, targetId, newestId })
|
|
2028
|
+
) {
|
|
2029
|
+
// 生成中 claude を再接続/remount の bind で kill しない (謎停止対策)。respawn を抑止して
|
|
2030
|
+
// 既存の生成中 claude を温存する。bindKey は記録して以降の remount を冪等化する
|
|
2031
|
+
// (targetId === newestId = 動いているセッション本人なので記録は正しい)。
|
|
2032
|
+
rebind = { ok: true, skipped: true }
|
|
2033
|
+
ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
2034
|
+
logger?.info(
|
|
2035
|
+
{ session: sessionName, session_id: targetId },
|
|
2036
|
+
"tui rebind: skipped respawn (turn in progress on the running session)",
|
|
2037
|
+
)
|
|
2000
2038
|
} else {
|
|
2001
2039
|
rebind = await rebindClaudeSession(sessionName, targetId, {
|
|
2002
2040
|
cwd,
|
package/src/state.mjs
CHANGED
|
@@ -597,6 +597,10 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
597
597
|
}
|
|
598
598
|
const defaultContextPct = detectContextPctFromText(text)
|
|
599
599
|
const defaultPermissionMode = detectPermissionModeFromText(text)
|
|
600
|
+
// スピナー行 (ライブ or 凍結) が画面に在るか。frozen 降格で status が waiting に落ちても、
|
|
601
|
+
// スピナーが画面に残っている = ターンはクリーンに終わっていない (ハング候補)。StallTracker が
|
|
602
|
+
// 「クリーン idle (スピナー無し) でターンを閉じる」判定に使い、frozen スピナーのハングを拾う。
|
|
603
|
+
const spinnerPresent = workingSpinnerLine(text) !== null
|
|
600
604
|
|
|
601
605
|
if (opts.plugins && opts.plugins.length) {
|
|
602
606
|
const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
|
|
@@ -616,6 +620,7 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
616
620
|
permission_mode:
|
|
617
621
|
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
618
622
|
stable: _computeStable(sessionName, status, text),
|
|
623
|
+
spinner_present: spinnerPresent,
|
|
619
624
|
// 出力領域 (揮発フッター除外) の署名。state loop が tick 間で比較し、純テキスト
|
|
620
625
|
// 生成 (子プロセスを spawn しないため proc 内省が busy=false と見るケース) を
|
|
621
626
|
// 「出力フロー中=busy」として検出するのに使う。
|
|
@@ -630,6 +635,7 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
630
635
|
context_pct: defaultContextPct,
|
|
631
636
|
permission_mode: defaultPermissionMode,
|
|
632
637
|
stable: _computeStable(sessionName, status, text),
|
|
638
|
+
spinner_present: spinnerPresent,
|
|
633
639
|
output_sig: _outputSignature(text),
|
|
634
640
|
}
|
|
635
641
|
}
|
|
@@ -648,4 +654,67 @@ export async function listSessionStates(opts = {}) {
|
|
|
648
654
|
)
|
|
649
655
|
}
|
|
650
656
|
|
|
657
|
+
/**
|
|
658
|
+
* arm 非依存・cap 非依存の「応答停止 (stall)」判定ステート。
|
|
659
|
+
*
|
|
660
|
+
* 背景: 旧実装の stalled は (a) proc 内省の armed (= バンドルフックの prompt_submit 由来) に全依存し、
|
|
661
|
+
* フック未発火/旧世代hookセッションでは永久に false、(b) readiness loop の cap(≈140s)で disarm されると
|
|
662
|
+
* stalled が消える (90〜140s だけ表示で以後恒久消失)、という二重の死角があった。本トラッカーは state loop が
|
|
663
|
+
* 観測する signal だけで turn の開閉と無進捗を判定し、arm にも cap にも依存しない:
|
|
664
|
+
* - turnOpen: processing/procBusy を観測したら true。クリーン idle (非progressing かつ スピナー行が画面に
|
|
665
|
+
* 無く stable) で false。armed (フックが効くセッション) は OR で常に開扱い (従来挙動の上位互換)。
|
|
666
|
+
* - 進捗 (processing or procBusy) があれば lastProgressAt を更新。turn が閉じている間も更新し、次ターンの
|
|
667
|
+
* クロックを毎 tick リセットする。
|
|
668
|
+
* - stalled = turnOpen かつ 無進捗が stallWarnMs 継続。frozen スピナー (画面に在るが status は frozen 降格で
|
|
669
|
+
* waiting) は spinnerPresent=true で turnOpen を維持するため、proc 子が無く出力も止まったハングを拾える。
|
|
670
|
+
* cap に依存しないため 140s 以後も消えない。
|
|
671
|
+
*/
|
|
672
|
+
export class StallTracker {
|
|
673
|
+
constructor({ stallWarnMs = 90_000 } = {}) {
|
|
674
|
+
this.stallWarnMs = Math.max(1000, stallWarnMs)
|
|
675
|
+
/** @type {Map<string, {turnOpen: boolean, lastProgressAt: number|null}>} */
|
|
676
|
+
this.byName = new Map()
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
_get(name) {
|
|
680
|
+
let s = this.byName.get(name)
|
|
681
|
+
if (!s) {
|
|
682
|
+
s = { turnOpen: false, lastProgressAt: null }
|
|
683
|
+
this.byName.set(name, s)
|
|
684
|
+
}
|
|
685
|
+
return s
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* @param {string} name
|
|
690
|
+
* @param {{armed?: boolean, processing?: boolean, procBusy?: boolean,
|
|
691
|
+
* spinnerPresent?: boolean, stable?: boolean, now: number}} sig
|
|
692
|
+
* @returns {{stalled: boolean, turnOpen: boolean}}
|
|
693
|
+
*/
|
|
694
|
+
observe(name, { armed = false, processing = false, procBusy = false, spinnerPresent = false, stable = false, now }) {
|
|
695
|
+
const s = this._get(name)
|
|
696
|
+
const progressing = processing || procBusy
|
|
697
|
+
if (progressing) {
|
|
698
|
+
s.turnOpen = true
|
|
699
|
+
} else if (!spinnerPresent && stable) {
|
|
700
|
+
// クリーン idle: 生成中でなく proc 非busy、スピナーも画面に無く出力安定 = ターン正常終了。
|
|
701
|
+
// frozen スピナー (spinnerPresent=true) や stable 未確定の間は閉じない (ハング候補を維持)。
|
|
702
|
+
s.turnOpen = false
|
|
703
|
+
}
|
|
704
|
+
const turnOpen = armed || s.turnOpen
|
|
705
|
+
if (progressing || !turnOpen) {
|
|
706
|
+
// 進捗あり、または turn が閉じている間はクロックを前進させ続ける (次ターンのクロックを毎 tick 0 に保つ)。
|
|
707
|
+
s.lastProgressAt = now
|
|
708
|
+
} else if (s.lastProgressAt == null) {
|
|
709
|
+
s.lastProgressAt = now
|
|
710
|
+
}
|
|
711
|
+
const stalled = turnOpen && now - s.lastProgressAt >= this.stallWarnMs
|
|
712
|
+
return { stalled, turnOpen }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
forget(name) {
|
|
716
|
+
this.byName.delete(name)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
651
720
|
export { STATUSES }
|
package/src/tmux.mjs
CHANGED
|
@@ -1220,6 +1220,29 @@ export async function recoverTuiInput(name, expectText, opts = {}) {
|
|
|
1220
1220
|
* @param {{cwd?:string,model?:string,permissionMode?:string,fresh?:boolean,logger?:object,tmuxBin?:string}} [opts]
|
|
1221
1221
|
* @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
|
|
1222
1222
|
*/
|
|
1223
|
+
/**
|
|
1224
|
+
* rebind (respawn-pane -k + claude --resume) を抑止すべきか判定する純ロジック。
|
|
1225
|
+
*
|
|
1226
|
+
* 背景 (謎停止対策): claude.tui.bind は TUI ビューの mount / 再接続のたびに来る。respawn-pane -k は
|
|
1227
|
+
* 現 claude を強制 kill するため、生成中に走ると in-flight の応答が失われる (jsonl 未確定)。冪等ガード
|
|
1228
|
+
* (tuiReboundSessions) は hub-agent 再起動直後は空で効かず、ブラウザ再接続の bind が生成中 claude を
|
|
1229
|
+
* kill する事故 (謎停止) を起こす。
|
|
1230
|
+
*
|
|
1231
|
+
* 抑止条件: 生成中 (generating) かつ「今動いているセッション (= 最新 jsonl = newestId) への再 bind」。
|
|
1232
|
+
* これは再接続/remount であり respawn は純粋に破壊的。逆に:
|
|
1233
|
+
* - fresh (+新規セッション要求) は明示操作なので respawn する。
|
|
1234
|
+
* - targetId !== newestId (別 session への明示 switch) は respawn する (生成中セッションは別ペイン文脈で温存)。
|
|
1235
|
+
* - 非生成中は従来どおり respawn する (idle claude の --resume 載せ替えは非破壊)。
|
|
1236
|
+
*
|
|
1237
|
+
* @param {{generating?: boolean, fresh?: boolean, targetId?: string|null, newestId?: string|null}} a
|
|
1238
|
+
* @returns {boolean}
|
|
1239
|
+
*/
|
|
1240
|
+
export function shouldSkipRebindRespawn({ generating, fresh, targetId, newestId } = {}) {
|
|
1241
|
+
if (fresh) return false
|
|
1242
|
+
if (!generating) return false
|
|
1243
|
+
return !!targetId && targetId === newestId
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1223
1246
|
export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
1224
1247
|
let cmd
|
|
1225
1248
|
try {
|