@cocorograph/hub-agent 0.6.99 → 0.7.1
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/claude-history.mjs +9 -0
- package/src/main.mjs +118 -28
- package/src/state.mjs +3 -0
- package/src/tmux.mjs +23 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -321,13 +321,22 @@ export function decideSessionRotation({
|
|
|
321
321
|
newestSessionId,
|
|
322
322
|
lastNotifiedNewId,
|
|
323
323
|
lastNotifiedAt,
|
|
324
|
+
lastNotifiedCount = 0,
|
|
324
325
|
now,
|
|
325
326
|
reNotifyMs = 4000,
|
|
327
|
+
maxReNotify = 5,
|
|
326
328
|
} = {}) {
|
|
327
329
|
if (!newestSessionId || !viewingSessionId) return { rotated: false }
|
|
328
330
|
if (newestSessionId === viewingSessionId) return { rotated: false }
|
|
329
331
|
if (newestSessionId === lastNotifiedNewId) {
|
|
330
332
|
// 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
|
|
333
|
+
// ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
|
|
334
|
+
// 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
|
|
335
|
+
// 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
|
|
336
|
+
// なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
|
|
337
|
+
if (lastNotifiedCount >= maxReNotify) {
|
|
338
|
+
return { rotated: false, capped: true }
|
|
339
|
+
}
|
|
331
340
|
if (
|
|
332
341
|
typeof now === "number" &&
|
|
333
342
|
typeof lastNotifiedAt === "number" &&
|
package/src/main.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
capturePane,
|
|
38
38
|
detectPermissionModeFromText,
|
|
39
39
|
detectSessionState,
|
|
40
|
+
invalidateSessionCache,
|
|
40
41
|
listSessionStates,
|
|
41
42
|
StallTracker,
|
|
42
43
|
} from "./state.mjs"
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
listWorktreeNameHistory,
|
|
69
70
|
listWorktreeStubs,
|
|
70
71
|
rebindClaudeSession,
|
|
72
|
+
shouldSkipRebindRespawn,
|
|
71
73
|
recoverTuiInput,
|
|
72
74
|
removeWorktree as removeWorktreeDir,
|
|
73
75
|
resumeWithMessage,
|
|
@@ -593,6 +595,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
593
595
|
// 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
|
|
594
596
|
// Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
|
|
595
597
|
const readinessTracker = new ReadinessTracker()
|
|
598
|
+
// claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
|
|
599
|
+
// respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
|
|
600
|
+
ctx.readinessTracker = readinessTracker
|
|
596
601
|
const stateLoop = startStateLoop({
|
|
597
602
|
client,
|
|
598
603
|
plugins,
|
|
@@ -891,17 +896,18 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
891
896
|
readinessTracker.arm(sessionName)
|
|
892
897
|
}
|
|
893
898
|
}
|
|
894
|
-
// 計装 (2026-06-19): ターン境界イベントと arm
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
899
|
+
// 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
|
|
900
|
+
// ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
|
|
901
|
+
if (STATE_TRACE) {
|
|
902
|
+
logger?.info(
|
|
903
|
+
{
|
|
904
|
+
session_name: sessionName,
|
|
905
|
+
event: data.event,
|
|
906
|
+
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
907
|
+
},
|
|
908
|
+
"session.event push",
|
|
909
|
+
)
|
|
910
|
+
}
|
|
905
911
|
client.send({
|
|
906
912
|
type: "session.event",
|
|
907
913
|
session_name: sessionName,
|
|
@@ -1037,7 +1043,15 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
|
|
|
1037
1043
|
// いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
|
|
1038
1044
|
// 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
|
|
1039
1045
|
// 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
|
|
1040
|
-
|
|
1046
|
+
// 出力フロー検知窓。state loop tick(5000ms)の整数倍にして、出力が止まった後の outputActive が
|
|
1047
|
+
// 5s tick 境界で true↔false フラッピングするのを防ぐ(8000 は非整数倍で proc_busy 単独反転 push が
|
|
1048
|
+
// 多発していた=perf監査。10000=2tick で安定側、影響は『出力停止とみなす猶予 8s→10s』のみ)。
|
|
1049
|
+
const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 10000)
|
|
1050
|
+
|
|
1051
|
+
// 状態信号の計装ログ(session.state/event push)を出すか。既定 OFF。障害解析時に
|
|
1052
|
+
// HUB_AGENT_STATE_TRACE=1 で一時的に有効化する(常時 info 出力はログ肥大の主因=perf監査で
|
|
1053
|
+
// 直近1h ログの約半分を占めると実測。挙動には一切影響しない純ログ)。
|
|
1054
|
+
const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
|
|
1041
1055
|
|
|
1042
1056
|
// 権威的な「応答停止 (ハング)」判定の無進捗しきい値。ターン進行中 (armed) なのに、実際の
|
|
1043
1057
|
// 進捗 (子プロセス実行中 / 出力フロー / ペインのライブ processing) がこの時間継続して観測
|
|
@@ -1175,10 +1189,10 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1175
1189
|
proc_busy: procBusy,
|
|
1176
1190
|
stalled,
|
|
1177
1191
|
})
|
|
1178
|
-
// 計装 (2026-06-19):
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1192
|
+
// 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
|
|
1193
|
+
// 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
|
|
1194
|
+
// proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
|
|
1195
|
+
if (STATE_TRACE) {
|
|
1182
1196
|
logger?.info(
|
|
1183
1197
|
{
|
|
1184
1198
|
session_name: s.session_name,
|
|
@@ -1193,6 +1207,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1193
1207
|
},
|
|
1194
1208
|
"session.state push",
|
|
1195
1209
|
)
|
|
1210
|
+
}
|
|
1196
1211
|
client.send({
|
|
1197
1212
|
type: "session.state",
|
|
1198
1213
|
session_name: s.session_name,
|
|
@@ -1204,6 +1219,22 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1204
1219
|
stalled,
|
|
1205
1220
|
})
|
|
1206
1221
|
}
|
|
1222
|
+
// 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
|
|
1223
|
+
// まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
|
|
1224
|
+
// 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
|
|
1225
|
+
// (states=[]) のときは全消去しないよう states.length>0 をガードにする。
|
|
1226
|
+
if (states.length > 0) {
|
|
1227
|
+
const liveNames = new Set(states.map((s) => s.session_name))
|
|
1228
|
+
for (const name of [...lastByName.keys()]) {
|
|
1229
|
+
if (liveNames.has(name)) continue
|
|
1230
|
+
invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
|
|
1231
|
+
stallTracker.forget(name) // StallTracker.byName
|
|
1232
|
+
readinessTracker?.forget?.(name) // ReadinessTracker.byName
|
|
1233
|
+
lastByName.delete(name)
|
|
1234
|
+
outputFlowByName.delete(name)
|
|
1235
|
+
lastTurnAtByName.delete(name)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1207
1238
|
}
|
|
1208
1239
|
} catch (err) {
|
|
1209
1240
|
logger?.warn({ err: err.message }, "state loop tick failed")
|
|
@@ -1471,13 +1502,26 @@ async function dispatch(msg, ctx) {
|
|
|
1471
1502
|
}
|
|
1472
1503
|
handleUntrackedPtyData(msg, ctx)
|
|
1473
1504
|
return
|
|
1474
|
-
case "pty.resize":
|
|
1475
|
-
ctx.ptyBridge.resize({
|
|
1505
|
+
case "pty.resize": {
|
|
1506
|
+
const resized = ctx.ptyBridge.resize({
|
|
1476
1507
|
stream_id: msg.stream_id,
|
|
1477
1508
|
cols: msg.cols,
|
|
1478
1509
|
rows: msg.rows,
|
|
1479
1510
|
})
|
|
1511
|
+
// 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
|
|
1512
|
+
// pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
|
|
1513
|
+
// stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
|
|
1514
|
+
// (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
|
|
1515
|
+
// false だが、その場合の再 attach は既存 stream への無害な再接続。
|
|
1516
|
+
if (!resized && msg.stream_id) {
|
|
1517
|
+
ctx.client.send({
|
|
1518
|
+
type: "pty.error",
|
|
1519
|
+
stream_id: msg.stream_id,
|
|
1520
|
+
error: "stream_missing",
|
|
1521
|
+
})
|
|
1522
|
+
}
|
|
1480
1523
|
return
|
|
1524
|
+
}
|
|
1481
1525
|
case "pty.detach":
|
|
1482
1526
|
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
1483
1527
|
return
|
|
@@ -1688,20 +1732,37 @@ async function dispatch(msg, ctx) {
|
|
|
1688
1732
|
const key = viewName || viewCwd
|
|
1689
1733
|
const prev = ctx.tuiRotationNotified.get(key)
|
|
1690
1734
|
const now = Date.now()
|
|
1691
|
-
const
|
|
1735
|
+
const prevNewId =
|
|
1736
|
+
prev && typeof prev === "object" ? prev.newId : prev
|
|
1737
|
+
const prevCount =
|
|
1738
|
+
prev && typeof prev === "object" ? (prev.count ?? 0) : 0
|
|
1739
|
+
const { rotated, newSessionId, capped } = decideSessionRotation({
|
|
1692
1740
|
viewingSessionId: viewSid,
|
|
1693
1741
|
newestSessionId: newestId,
|
|
1694
|
-
// 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
|
|
1695
|
-
lastNotifiedNewId:
|
|
1696
|
-
prev && typeof prev === "object" ? prev.newId : prev,
|
|
1742
|
+
// 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
|
|
1743
|
+
lastNotifiedNewId: prevNewId,
|
|
1697
1744
|
lastNotifiedAt:
|
|
1698
1745
|
prev && typeof prev === "object" ? prev.ts : null,
|
|
1746
|
+
lastNotifiedCount: prevCount,
|
|
1699
1747
|
now,
|
|
1700
1748
|
})
|
|
1749
|
+
if (capped) {
|
|
1750
|
+
// 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
|
|
1751
|
+
// rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
|
|
1752
|
+
logger.warn(
|
|
1753
|
+
{ session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
|
|
1754
|
+
"tui session rotation: capped (frontend not adopting; stopped re-notify)",
|
|
1755
|
+
)
|
|
1756
|
+
return
|
|
1757
|
+
}
|
|
1701
1758
|
if (!rotated) return
|
|
1702
|
-
// {newId, ts}
|
|
1703
|
-
//
|
|
1704
|
-
ctx.tuiRotationNotified.set(key, {
|
|
1759
|
+
// {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
|
|
1760
|
+
// newId が変われば count を 1 にリセット、同一なら加算する。
|
|
1761
|
+
ctx.tuiRotationNotified.set(key, {
|
|
1762
|
+
newId: newSessionId,
|
|
1763
|
+
ts: now,
|
|
1764
|
+
count: newSessionId === prevNewId ? prevCount + 1 : 1,
|
|
1765
|
+
})
|
|
1705
1766
|
// 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
|
|
1706
1767
|
// respawn (claude 再起動) してしまうのを防ぐ。
|
|
1707
1768
|
if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
|
|
@@ -1962,12 +2023,16 @@ async function dispatch(msg, ctx) {
|
|
|
1962
2023
|
// resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
|
|
1963
2024
|
// 生成され、frontend は回転検知 / sessions 応答で拾う。
|
|
1964
2025
|
const fresh = msg.fresh === true
|
|
1965
|
-
// 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)
|
|
2026
|
+
// 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。あわせて
|
|
2027
|
+
// 現在動いているセッション (= cwd の最新 jsonl = newestId) も解決する。生成中ガードで
|
|
2028
|
+
// 「再接続/remount による同一セッションへの再 bind」か「別 session への明示 switch」かを
|
|
2029
|
+
// 区別するのに使う (前者は respawn を抑止して生成中 claude を温存する)。
|
|
1966
2030
|
let targetId =
|
|
1967
2031
|
typeof msg.session_id === "string" && msg.session_id
|
|
1968
2032
|
? msg.session_id
|
|
1969
2033
|
: null
|
|
1970
|
-
|
|
2034
|
+
let newestId = null
|
|
2035
|
+
if (!fresh && cwd) {
|
|
1971
2036
|
const projectsRoot = await getActiveProjectsRoot()
|
|
1972
2037
|
const { sessions } = await listSessions({
|
|
1973
2038
|
cwd,
|
|
@@ -1975,7 +2040,20 @@ async function dispatch(msg, ctx) {
|
|
|
1975
2040
|
limit: 1,
|
|
1976
2041
|
logger,
|
|
1977
2042
|
})
|
|
1978
|
-
|
|
2043
|
+
newestId = sessions?.[0]?.session_id || null
|
|
2044
|
+
if (!targetId) targetId = newestId
|
|
2045
|
+
}
|
|
2046
|
+
// 生成中ガード (謎停止対策): respawn-pane -k は生成中 claude を強制 kill し in-flight 応答を失う。
|
|
2047
|
+
// hub-agent 再起動直後は tuiReboundSessions が空で冪等ガードが効かず、ブラウザ再接続の bind が
|
|
2048
|
+
// 生成中 claude を kill する。armed (ターン進行中) か pane=processing なら生成中とみなす。
|
|
2049
|
+
let generating = ctx.readinessTracker?.isArmed?.(sessionName) === true
|
|
2050
|
+
if (!generating && sessionName) {
|
|
2051
|
+
try {
|
|
2052
|
+
const snap = await detectSessionState(sessionName, {})
|
|
2053
|
+
if (snap?.status === "processing") generating = true
|
|
2054
|
+
} catch {
|
|
2055
|
+
// capture 失敗時は据え置き (生成中とみなさない = 従来挙動)
|
|
2056
|
+
}
|
|
1979
2057
|
}
|
|
1980
2058
|
// 2) SDK を停止 (cwd 全体 + 対象 id)。
|
|
1981
2059
|
let stoppedSdk = 0
|
|
@@ -2002,6 +2080,18 @@ async function dispatch(msg, ctx) {
|
|
|
2002
2080
|
if (bindKey && sessionName && (fresh || targetId)) {
|
|
2003
2081
|
if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
|
|
2004
2082
|
rebind = { ok: true, skipped: true }
|
|
2083
|
+
} else if (
|
|
2084
|
+
shouldSkipRebindRespawn({ generating, fresh, targetId, newestId })
|
|
2085
|
+
) {
|
|
2086
|
+
// 生成中 claude を再接続/remount の bind で kill しない (謎停止対策)。respawn を抑止して
|
|
2087
|
+
// 既存の生成中 claude を温存する。bindKey は記録して以降の remount を冪等化する
|
|
2088
|
+
// (targetId === newestId = 動いているセッション本人なので記録は正しい)。
|
|
2089
|
+
rebind = { ok: true, skipped: true }
|
|
2090
|
+
ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
2091
|
+
logger?.info(
|
|
2092
|
+
{ session: sessionName, session_id: targetId },
|
|
2093
|
+
"tui rebind: skipped respawn (turn in progress on the running session)",
|
|
2094
|
+
)
|
|
2005
2095
|
} else {
|
|
2006
2096
|
rebind = await rebindClaudeSession(sessionName, targetId, {
|
|
2007
2097
|
cwd,
|
package/src/state.mjs
CHANGED
|
@@ -47,12 +47,15 @@ export function invalidateSessionCache(sessionName) {
|
|
|
47
47
|
_cwdCache.clear()
|
|
48
48
|
_statusGate.clear()
|
|
49
49
|
_spinnerFreezeByName.clear()
|
|
50
|
+
_stabilityByName.clear()
|
|
50
51
|
return
|
|
51
52
|
}
|
|
52
53
|
_captureCache.delete(sessionName)
|
|
53
54
|
_cwdCache.delete(sessionName)
|
|
54
55
|
_statusGate.delete(sessionName)
|
|
55
56
|
_spinnerFreezeByName.delete(sessionName)
|
|
57
|
+
// _stabilityByName も per-session で消す (消滅セッションの GC 漏れ=メモリリーク対策, perf監査)。
|
|
58
|
+
_stabilityByName.delete(sessionName)
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
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 {
|