@cocorograph/hub-agent 0.7.18 → 0.7.20
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/extract-paragraph.mjs +25 -0
- package/src/main.mjs +153 -40
- package/src/tmux.mjs +45 -0
- package/src/usage.mjs +95 -12
package/package.json
CHANGED
|
@@ -86,6 +86,31 @@ export function extractLastAssistantText(paneText) {
|
|
|
86
86
|
return text
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* permission/質問カードに出す「直前アシスタント説明」の採用優先順位を決める純関数。
|
|
91
|
+
*
|
|
92
|
+
* 症状4 根治: フック (bundle) 由来の context_text は PreToolUse 発火時点で jsonl が
|
|
93
|
+
* 前ターン止まり (フックがブロック中は claude が現ターンを commit しない=デッドロック)
|
|
94
|
+
* のため「前ターンの古い説明」を掴む取り違えを起こす。一方 hub-agent が tmux
|
|
95
|
+
* capture-pane で抜く liveContext は「いま描画中の現ターン本文」なので回答前に
|
|
96
|
+
* 信頼できる唯一のソース。よって **liveContext を最優先**し、フック由来は後方互換の
|
|
97
|
+
* フォールバックとしてのみ採用する (旧 bundle が context_text を送ってきても汚染しない)。
|
|
98
|
+
* 両方とも空/非文字列なら null (= 説明なしで安全縮退)。
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} hookContextText フック (bundle) が同梱した context_text (旧経路)
|
|
101
|
+
* @param {unknown} liveContext capture-pane から抽出した現ターン説明
|
|
102
|
+
* @returns {string|null}
|
|
103
|
+
*/
|
|
104
|
+
export function pickContextText(hookContextText, liveContext) {
|
|
105
|
+
const live =
|
|
106
|
+
typeof liveContext === "string" && liveContext.trim() ? liveContext : null
|
|
107
|
+
const hook =
|
|
108
|
+
typeof hookContextText === "string" && hookContextText.trim()
|
|
109
|
+
? hookContextText
|
|
110
|
+
: null
|
|
111
|
+
return live || hook || null
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
export const __test = {
|
|
90
115
|
_MAX_CHARS,
|
|
91
116
|
_MIN_CHARS,
|
package/src/main.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import path from "node:path"
|
|
|
20
20
|
import pino from "pino"
|
|
21
21
|
|
|
22
22
|
import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
|
|
23
|
-
import { extractLastAssistantText } from "./extract-paragraph.mjs"
|
|
23
|
+
import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
|
|
24
24
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
25
25
|
import { WsClient } from "./ws-client.mjs"
|
|
26
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
listWorktreeNameHistory,
|
|
72
72
|
listWorktreeStubs,
|
|
73
73
|
isPaneRunningClaude,
|
|
74
|
+
paneClaudeAliveOrUnknown,
|
|
74
75
|
rebindClaudeSession,
|
|
75
76
|
shouldSkipRebindRespawn,
|
|
76
77
|
recoverTuiInput,
|
|
@@ -91,6 +92,7 @@ import {
|
|
|
91
92
|
contextWindowSize,
|
|
92
93
|
getSessionUsages,
|
|
93
94
|
getUsage,
|
|
95
|
+
isFreshUnboundBind,
|
|
94
96
|
recordChatRateLimit,
|
|
95
97
|
turnActiveForCwd,
|
|
96
98
|
} from "./usage.mjs"
|
|
@@ -667,6 +669,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
667
669
|
// セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
|
|
668
670
|
boundSessions: ctx.tuiReboundSessions,
|
|
669
671
|
})
|
|
672
|
+
// T1: tmux.list_sessions poll fallback で turn_active/proc_busy/child_busy/stalled を
|
|
673
|
+
// 同梱できるよう、state loop の最新値スナップショット accessor を ctx に晒す。
|
|
674
|
+
// push 専用の 4 信号を poll でも回復可能にする (生産点は state loop で単一のまま、
|
|
675
|
+
// 取り出し口を 2 つに増やすだけ = §5 の用途別出し分けを侵さない)。
|
|
676
|
+
ctx.getSessionStateSnapshot = stateLoop.getSnapshot
|
|
670
677
|
// bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
|
|
671
678
|
// に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
|
|
672
679
|
// text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
|
|
@@ -700,17 +707,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
700
707
|
/* ignore */
|
|
701
708
|
}
|
|
702
709
|
}
|
|
703
|
-
// ── 直前アシスタント説明の抽出 (
|
|
710
|
+
// ── 直前アシスタント説明の抽出 (症状4 根治) ─────────────────────────────
|
|
704
711
|
// PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
|
|
705
|
-
// している間 claude
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
+
// している間 claude は現ターンを jsonl に commit しない=デッドロック構造) の
|
|
713
|
+
// ため、フック側の transcript 抽出は前ターンの古い説明を掴む「取り違え」を
|
|
714
|
+
// 起こす。代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上
|
|
715
|
+
// に既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
|
|
716
|
+
// 抜き出す。これが回答前に説明を届ける唯一の信頼ソースなので、フック由来の
|
|
717
|
+
// context_text の有無に関わらず **常に** capture-pane を試み、pickContextText で
|
|
718
|
+
// liveContext を最優先する (旧 bundle が古い context_text を送ってきても汚染
|
|
719
|
+
// しない)。抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
|
|
712
720
|
let liveContext = null
|
|
713
|
-
if (
|
|
721
|
+
if (cwd) {
|
|
714
722
|
try {
|
|
715
723
|
const sessions = await listTmuxSessions({ logger })
|
|
716
724
|
const match = Array.isArray(sessions)
|
|
@@ -736,9 +744,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
736
744
|
tool_name,
|
|
737
745
|
input,
|
|
738
746
|
// 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
|
|
739
|
-
// 優先順位: (1)
|
|
740
|
-
//
|
|
741
|
-
context_text: context_text
|
|
747
|
+
// 優先順位: (1) capture-pane で抽出した現ターンの liveContext、(2) フック由来
|
|
748
|
+
// の context_text (後方互換フォールバック)、(3) null。pickContextText 参照。
|
|
749
|
+
context_text: pickContextText(context_text, liveContext),
|
|
742
750
|
})
|
|
743
751
|
},
|
|
744
752
|
)
|
|
@@ -1192,7 +1200,47 @@ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
|
|
|
1192
1200
|
// TURN_STALL_WARN_MS と揃える。
|
|
1193
1201
|
const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
|
|
1194
1202
|
|
|
1195
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* tmux.list_sessions 応答に同梱する session オブジェクトを組み立てる純関数。
|
|
1205
|
+
*
|
|
1206
|
+
* T1: push 専用だった 4 信号(turn_active/proc_busy/child_busy/stalled)を poll 応答にも
|
|
1207
|
+
* 同梱して、WS 再接続直後/push 取りこぼし時のドット系古値固着を解消する。snapshot 未取得
|
|
1208
|
+
* (新規セッション cold window <5s) なら 4 信号を付与せず、frontend は legacy フォールバック
|
|
1209
|
+
* (現状維持で退行なし)。設計正本 §5 の不変条件は侵さない(生産点は startStateLoop で単一の
|
|
1210
|
+
* まま、取り出し口を push と poll の 2 つに増やすだけ)。
|
|
1211
|
+
*
|
|
1212
|
+
* 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
|
|
1213
|
+
* 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
|
|
1214
|
+
*
|
|
1215
|
+
* @param {Array} sessions tmux.list_sessions が返した素の session 行
|
|
1216
|
+
* @param {Map<string, object>} lastEventByName session_name → 最新 hook event
|
|
1217
|
+
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
1218
|
+
* accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
|
|
1219
|
+
*/
|
|
1220
|
+
export function enrichTmuxSessionsForListResponse(
|
|
1221
|
+
sessions,
|
|
1222
|
+
lastEventByName,
|
|
1223
|
+
getSnap,
|
|
1224
|
+
) {
|
|
1225
|
+
return sessions.map((s) => {
|
|
1226
|
+
const base = {
|
|
1227
|
+
...s,
|
|
1228
|
+
last_event: lastEventByName.get(s.name) || null,
|
|
1229
|
+
}
|
|
1230
|
+
if (!getSnap) return base
|
|
1231
|
+
const snap = getSnap(s.name)
|
|
1232
|
+
if (!snap) return base
|
|
1233
|
+
// snapshot は startStateLoop.getSnapshot が shallow copy で返しているため、ここでの
|
|
1234
|
+
// 上書きが lastByName を汚染することはない(症状A/B 再発防止)。
|
|
1235
|
+
base.turn_active = snap.turn_active
|
|
1236
|
+
base.proc_busy = snap.proc_busy
|
|
1237
|
+
base.child_busy = snap.child_busy
|
|
1238
|
+
base.stalled = snap.stalled
|
|
1239
|
+
return base
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
|
|
1196
1244
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
1197
1245
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
1198
1246
|
// (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
|
|
@@ -1312,13 +1360,26 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1312
1360
|
// bound session_id を渡すことで、同一 cwd dir 内の別セッション / サブエージェント /
|
|
1313
1361
|
// headless `claude -p` / `/clear` ローテの jsonl を「最新」と取り違えて turn_active を
|
|
1314
1362
|
// 誤る (cross-activity contamination = メタ欠陥#1) のを防ぐ。
|
|
1363
|
+
// T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
|
|
1364
|
+
// から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
|
|
1365
|
+
// 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
|
|
1315
1366
|
let turnActive = null
|
|
1316
1367
|
if (chat?.status === "processing") {
|
|
1317
1368
|
turnActive = true
|
|
1318
1369
|
} else if (chat?.status === "waiting" || chat?.status === "idle") {
|
|
1319
1370
|
turnActive = false
|
|
1371
|
+
} else if (s.cwd && isFreshUnboundBind(boundSessions?.get(s.session_name))) {
|
|
1372
|
+
// 症状2b 根治: fresh bind プレースホルダの間は、別アクティビティ jsonl を mtime 最新で
|
|
1373
|
+
// 取り違える汚染を避けて turn_active を出さない (null=不明)。実セッション id へ adopt
|
|
1374
|
+
// されれば下の通常経路に乗る。青を出さない安全側にのみ倒すため固着青は生まない。
|
|
1375
|
+
turnActive = null
|
|
1320
1376
|
} else if (s.cwd) {
|
|
1321
|
-
turnActive = await turnActiveForCwd(
|
|
1377
|
+
turnActive = await turnActiveForCwd(
|
|
1378
|
+
s.cwd,
|
|
1379
|
+
boundSessionId(boundSessions, s.session_name),
|
|
1380
|
+
s.session_name,
|
|
1381
|
+
{ paneAliveProbe: paneClaudeAliveOrUnknown },
|
|
1382
|
+
)
|
|
1322
1383
|
}
|
|
1323
1384
|
|
|
1324
1385
|
const prev = lastByName.get(s.session_name)
|
|
@@ -1388,6 +1449,15 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1388
1449
|
const liveNames = new Set(states.map((s) => s.session_name))
|
|
1389
1450
|
for (const name of [...lastByName.keys()]) {
|
|
1390
1451
|
if (liveNames.has(name)) continue
|
|
1452
|
+
// 終端を能動 broadcast (症状3 根治): tmux から消えた = crash/外部 kill/別経路
|
|
1453
|
+
// 終了。explicit kill は kill_session ハンドラが session.gone を出すが、それ以外
|
|
1454
|
+
// の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
|
|
1455
|
+
// 'down' に倒せるよう、ローカル Map を forget する前に通知する。
|
|
1456
|
+
try {
|
|
1457
|
+
client.send({ type: "session.gone", session_name: name })
|
|
1458
|
+
} catch {
|
|
1459
|
+
/* ignore */
|
|
1460
|
+
}
|
|
1391
1461
|
invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
|
|
1392
1462
|
stallTracker.forget(name) // StallTracker.byName
|
|
1393
1463
|
readinessTracker?.forget?.(name) // ReadinessTracker.byName
|
|
@@ -1417,6 +1487,23 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1417
1487
|
clearTimeout(t0)
|
|
1418
1488
|
client.off?.("open", onReopen)
|
|
1419
1489
|
},
|
|
1490
|
+
/**
|
|
1491
|
+
* tmux.list_sessions poll fallback 用の session.state snapshot を返す。
|
|
1492
|
+
*
|
|
1493
|
+
* 背景: session.state push は差分送信(prev 比較で変化時のみ送信)のため、WS 再接続
|
|
1494
|
+
* 直後や push 取りこぼし時にドット権威信号(turn_active/proc_busy/child_busy/stalled)が
|
|
1495
|
+
* 古値固着し得る(2026-06-28 ワークフローで検出した「事故的非対称」)。tmux.list_sessions
|
|
1496
|
+
* poll の応答にこれら 4 信号を同梱することで、status/context_pct と同じ精度で poll 経路
|
|
1497
|
+
* からも最新化できるようにする(T1)。
|
|
1498
|
+
*
|
|
1499
|
+
* shallow copy で返却して呼び出し側の mutate が lastByName を汚染するのを防ぐ
|
|
1500
|
+
* (state loop の差分送信ロジックが prev 比較で誤判定するのを避ける = 症状A/B 再発防止)。
|
|
1501
|
+
* 名前が未知 or まだ tick が走っていなければ null(frontend は legacy フォールバックへ)。
|
|
1502
|
+
*/
|
|
1503
|
+
getSnapshot(name) {
|
|
1504
|
+
const entry = lastByName.get(name)
|
|
1505
|
+
return entry ? { ...entry } : null
|
|
1506
|
+
},
|
|
1420
1507
|
}
|
|
1421
1508
|
}
|
|
1422
1509
|
|
|
@@ -1681,6 +1768,51 @@ export async function handleQueueFlush(msg, ctx) {
|
|
|
1681
1768
|
})
|
|
1682
1769
|
}
|
|
1683
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
|
|
1773
|
+
* tmux.kill_session.result を返したうえで、kill できた各 session について
|
|
1774
|
+
* **session.gone を能動 broadcast** する。
|
|
1775
|
+
*
|
|
1776
|
+
* 不変条件: session の終端 (kill/crash/外部終了) は、同一ホストで実体を観測できる
|
|
1777
|
+
* agent が正本として能動 push する。これが無いと frontend は kill 時点の turn_active=true
|
|
1778
|
+
* を keep-last したままステータスドットが青で固着する。最後の 1 セッションを kill した
|
|
1779
|
+
* 場合 state loop の GC は states.length>0 ガードで発火しないため、explicit kill 経路の
|
|
1780
|
+
* ここから session.gone を出すのが確実 (GC は crash/外部 kill の補完)。
|
|
1781
|
+
*
|
|
1782
|
+
* テストから直接呼べるよう export。kill 実体 (killManySessions) は ctx で差し替え可能。
|
|
1783
|
+
*/
|
|
1784
|
+
export async function handleKillSession(msg, ctx) {
|
|
1785
|
+
const killFn = ctx.killManySessions || killManySessions
|
|
1786
|
+
const names = Array.isArray(msg.session_names)
|
|
1787
|
+
? msg.session_names
|
|
1788
|
+
: msg.session_name
|
|
1789
|
+
? [msg.session_name]
|
|
1790
|
+
: []
|
|
1791
|
+
if (names.length === 0) {
|
|
1792
|
+
ctx.client.send({
|
|
1793
|
+
type: "tmux.kill_session.result",
|
|
1794
|
+
request_id: msg.request_id,
|
|
1795
|
+
killed: [],
|
|
1796
|
+
failed: [{ name: "", reason: "session_name(s) required" }],
|
|
1797
|
+
})
|
|
1798
|
+
return
|
|
1799
|
+
}
|
|
1800
|
+
const r = await killFn(names)
|
|
1801
|
+
ctx.client.send({
|
|
1802
|
+
type: "tmux.kill_session.result",
|
|
1803
|
+
request_id: msg.request_id,
|
|
1804
|
+
killed: r.killed,
|
|
1805
|
+
failed: r.failed,
|
|
1806
|
+
})
|
|
1807
|
+
for (const name of r.killed) {
|
|
1808
|
+
try {
|
|
1809
|
+
ctx.client.send({ type: "session.gone", session_name: name })
|
|
1810
|
+
} catch {
|
|
1811
|
+
/* ignore */
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1684
1816
|
/**
|
|
1685
1817
|
* claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
|
|
1686
1818
|
* 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
|
|
@@ -2922,10 +3054,11 @@ async function dispatch(msg, ctx) {
|
|
|
2922
3054
|
// fs.watch 由来の session.event push は揮発性のため、frontend が新規
|
|
2923
3055
|
// マウントすると過去 event を取れず全グレー表示になる事象を解消する。
|
|
2924
3056
|
const lastEventByName = await readAllSessionEvents()
|
|
2925
|
-
const enriched =
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
3057
|
+
const enriched = enrichTmuxSessionsForListResponse(
|
|
3058
|
+
sessions,
|
|
3059
|
+
lastEventByName,
|
|
3060
|
+
ctx.getSessionStateSnapshot,
|
|
3061
|
+
)
|
|
2929
3062
|
// cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
|
|
2930
3063
|
// filesystem 上は存在するが tmux session が無い worktree dir のリストを
|
|
2931
3064
|
// 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
|
|
@@ -3119,27 +3252,7 @@ async function dispatch(msg, ctx) {
|
|
|
3119
3252
|
return
|
|
3120
3253
|
}
|
|
3121
3254
|
case "tmux.kill_session": {
|
|
3122
|
-
|
|
3123
|
-
? msg.session_names
|
|
3124
|
-
: msg.session_name
|
|
3125
|
-
? [msg.session_name]
|
|
3126
|
-
: []
|
|
3127
|
-
if (names.length === 0) {
|
|
3128
|
-
ctx.client.send({
|
|
3129
|
-
type: "tmux.kill_session.result",
|
|
3130
|
-
request_id: msg.request_id,
|
|
3131
|
-
killed: [],
|
|
3132
|
-
failed: [{ name: "", reason: "session_name(s) required" }],
|
|
3133
|
-
})
|
|
3134
|
-
return
|
|
3135
|
-
}
|
|
3136
|
-
const r = await killManySessions(names)
|
|
3137
|
-
ctx.client.send({
|
|
3138
|
-
type: "tmux.kill_session.result",
|
|
3139
|
-
request_id: msg.request_id,
|
|
3140
|
-
killed: r.killed,
|
|
3141
|
-
failed: r.failed,
|
|
3142
|
-
})
|
|
3255
|
+
await handleKillSession(msg, ctx)
|
|
3143
3256
|
return
|
|
3144
3257
|
}
|
|
3145
3258
|
case "skills.request": {
|
package/src/tmux.mjs
CHANGED
|
@@ -1438,6 +1438,51 @@ export async function isPaneRunningClaude(name, opts = {}) {
|
|
|
1438
1438
|
}
|
|
1439
1439
|
}
|
|
1440
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* pane_current_command から claude プロセスの生存を 3 値で返す (T2)。
|
|
1443
|
+
*
|
|
1444
|
+
* `isPaneRunningClaude` は判定不能時に false (= respawn 許可) に倒す保守的な 2 値版で、
|
|
1445
|
+
* `shouldSkipRebindRespawn` の安全弁(「動いている claude は誰の要求でも殺さない」)に
|
|
1446
|
+
* 使われている。これに対して T2 (`scanTurnActive` の tool_use 末尾クラッシュ判定) は
|
|
1447
|
+
* 「claude が確実に死んだ ('dead') ときだけ active=false に倒し、判定不能 ('unknown') /
|
|
1448
|
+
* 生存 ('alive') では active を維持する」という symmetry が逆向きの設計が必要で、
|
|
1449
|
+
* 既存 isPaneRunningClaude の意味論を再利用すると tmux 失敗 → false → 誤って idle 化
|
|
1450
|
+
* = 偽陰性増加 = 中断後の長時間ツール中ペインを灰化させてしまう。
|
|
1451
|
+
*
|
|
1452
|
+
* 3 値の意味:
|
|
1453
|
+
* - 'dead' : pane の前景がシェル (fish/bash/zsh/sh/dash/tmux/login) = claude プロセス
|
|
1454
|
+
* 突然死 (中断マーカー無しの crash) でシェルに戻った確証あり
|
|
1455
|
+
* - 'alive' : pane の前景がシェル以外 (claude / claude.exe / node 等) = 生存
|
|
1456
|
+
* - 'unknown' : tmux 失敗 / 空出力 = 判定不能 = 安全側で active 維持に倒す
|
|
1457
|
+
*
|
|
1458
|
+
* 既存 isPaneRunningClaude は触らない (shouldSkipRebindRespawn の意味論互換のため
|
|
1459
|
+
* 独立 export とする)。命名 (`AliveOrUnknown`) も呼び出し側で「3 値である」ことを
|
|
1460
|
+
* 明示するため。
|
|
1461
|
+
*
|
|
1462
|
+
* @param {string} name
|
|
1463
|
+
* @param {{ tmux?: string }} [opts]
|
|
1464
|
+
* @returns {Promise<'alive' | 'dead' | 'unknown'>}
|
|
1465
|
+
*/
|
|
1466
|
+
export async function paneClaudeAliveOrUnknown(name, opts = {}) {
|
|
1467
|
+
if (!name) return "unknown"
|
|
1468
|
+
try {
|
|
1469
|
+
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
1470
|
+
"display-message",
|
|
1471
|
+
"-p",
|
|
1472
|
+
"-t",
|
|
1473
|
+
`${name}:`,
|
|
1474
|
+
"-F",
|
|
1475
|
+
"#{pane_current_command}",
|
|
1476
|
+
])
|
|
1477
|
+
const cmd = (stdout || "").trim().toLowerCase()
|
|
1478
|
+
if (!cmd) return "unknown"
|
|
1479
|
+
const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash", "tmux", "login"])
|
|
1480
|
+
return SHELLS.has(cmd) ? "dead" : "alive"
|
|
1481
|
+
} catch {
|
|
1482
|
+
return "unknown"
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1441
1486
|
export function shouldSkipRebindRespawn({
|
|
1442
1487
|
generating,
|
|
1443
1488
|
fresh,
|
package/src/usage.mjs
CHANGED
|
@@ -603,6 +603,28 @@ export function boundSessionId(boundSessions, name) {
|
|
|
603
603
|
return typeof v === "string" && v && !v.startsWith("fresh:") ? v : null
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
/**
|
|
607
|
+
* その tmux セッションが「fresh bind プレースホルダ (`fresh:<req>`)」の状態か判定する
|
|
608
|
+
* (症状2b 根治)。
|
|
609
|
+
*
|
|
610
|
+
* fresh bind は「新規セッションを開始する」意思表示で、まだ実セッション id へ adopt されて
|
|
611
|
+
* おらず、このペインが走らせている jsonl が確定していない。この状態で turn_active を
|
|
612
|
+
* `resolveTargetJsonl` の mtime 最新フォールバックから導くと、同一 cwd-encode dir に堆積した
|
|
613
|
+
* 別アクティビティ (headless `claude -p` / subagent / 並走セッション / 旧 jsonl) を「最新」と
|
|
614
|
+
* 取り違えて **偽の生成中 (青ドット) を出し、新規セッションで無操作なのに三点リーダーが固着** する。
|
|
615
|
+
*
|
|
616
|
+
* よって呼び出し側 (state loop) は fresh プレースホルダの間 turn_active を null (不明) に倒す。
|
|
617
|
+
* これは安全側 (青を出さない方向) にしか倒さないため、偽陽性の青固着を生むことはない。生成中の
|
|
618
|
+
* in-chat スピナーは frontend が turnOverride / session.event(prompt_submit) で別途駆動するため、
|
|
619
|
+
* fresh セッションの実生成中表示は保たれる (本判定はサイドバー/ヘッダーのドット権威のみに効く)。
|
|
620
|
+
*
|
|
621
|
+
* @param {string|null|undefined} boundVal `boundSessions.get(session_name)` の生値
|
|
622
|
+
* @returns {boolean}
|
|
623
|
+
*/
|
|
624
|
+
export function isFreshUnboundBind(boundVal) {
|
|
625
|
+
return typeof boundVal === "string" && boundVal.startsWith("fresh:")
|
|
626
|
+
}
|
|
627
|
+
|
|
606
628
|
/**
|
|
607
629
|
* 指定 cwd の Claude セッション jsonl から context% (USED %) を算出する。
|
|
608
630
|
*
|
|
@@ -725,9 +747,18 @@ const TURN_END_STOP_REASONS = new Set([
|
|
|
725
747
|
// 移植漏れた鮮度フロアで、Esc 中断後に放置した jsonl 等が永久に turn_active=true (青) に
|
|
726
748
|
// 居座り 0.7.17 の「赤=閉じろナッジ」を相殺していた問題を根治する。
|
|
727
749
|
// ※ tool_use 末尾 (assistant が active を立てたケース) は floor しない (frontend と同じ。
|
|
728
|
-
// 長時間ツール実行を誤って畳まない)
|
|
750
|
+
// 長時間ツール実行を誤って畳まない)。ツール中クラッシュは下記 STALE_TOOL_USE_MS + PID 生存判定。
|
|
729
751
|
const STALE_USER_TAIL_MS = 60_000
|
|
730
752
|
|
|
753
|
+
// T2: tool_use 末尾 active (assistant 由来) の「中断マーカー無しのプロセス突然死」検知用フロア。
|
|
754
|
+
// scanTurnActive は tool_use 末尾を floor しない (長時間ツールの偽陰性を防ぐ) ため、
|
|
755
|
+
// claude プロセスが clean に死ぬ (Stop hook を経ず crash) ケースで永久青固着し得る。
|
|
756
|
+
// turnActiveForCwd で tool_use 末尾 active + この時間経過時に tmux pane_current_command で
|
|
757
|
+
// claude プロセス生存を確認し、'dead' (= シェル前景) なら active=false に倒す。
|
|
758
|
+
// 'alive' / 'unknown' (tmux 失敗) は active 維持 = 長時間ツール / tmux 失敗時の偽陰性ゼロ。
|
|
759
|
+
// 5s は「pane scrape も capture もすり抜けた assistant 行が確実に書かれた猶予」を最短で取る値。
|
|
760
|
+
const STALE_TOOL_USE_MS = 5_000
|
|
761
|
+
|
|
731
762
|
/** jsonl の user 行から表示テキスト (string content / text ブロック連結) を取り出す。
|
|
732
763
|
* コマンド echo (`<command-name>`) 判定に使う。 */
|
|
733
764
|
function userEventText(d) {
|
|
@@ -775,12 +806,21 @@ function parseEntryTs(ts) {
|
|
|
775
806
|
* @param {number} [now] テスト用の現在時刻 (ms)。省略時は Date.now()
|
|
776
807
|
* @returns {boolean}
|
|
777
808
|
*/
|
|
778
|
-
|
|
779
|
-
|
|
809
|
+
/**
|
|
810
|
+
* jsonl テキストを走査して「最終 active 状態 + 何が立てたか + その時刻」の 3 つ組を返す
|
|
811
|
+
* 内部ヘルパー (T2)。scanTurnActive は本関数の `.active` を返す薄ラッパで API 互換。
|
|
812
|
+
* turnActiveForCwd は assistant 由来 active の鮮度判定で PID 生存 probe を挟むために
|
|
813
|
+
* activeBy/activeSinceTs を必要とする。
|
|
814
|
+
*
|
|
815
|
+
* @returns {{active: boolean, activeBy: 'user'|'assistant'|null, activeSinceTs: number|null}}
|
|
816
|
+
*/
|
|
817
|
+
export function _scanTurnState(text, now = Date.now()) {
|
|
818
|
+
const empty = { active: false, activeBy: null, activeSinceTs: null }
|
|
819
|
+
if (!text) return empty
|
|
780
820
|
let active = false
|
|
781
821
|
let lastTurnEnded = true
|
|
782
822
|
// 最終 active=true を立てた要因 ("user"=実入力 / "assistant"=tool_use) と、その入力の時刻。
|
|
783
|
-
// 鮮度フロアは "user"
|
|
823
|
+
// 鮮度フロアは "user" 由来かつ古いときだけ適用する (T2 は assistant 由来でも別フロアで probe)。
|
|
784
824
|
let activeBy = null
|
|
785
825
|
let activeSinceTs = null
|
|
786
826
|
const lines = text.split("\n")
|
|
@@ -836,16 +876,22 @@ export function scanTurnActive(text, now = Date.now()) {
|
|
|
836
876
|
}
|
|
837
877
|
}
|
|
838
878
|
}
|
|
839
|
-
//
|
|
879
|
+
// 鮮度フロア (user 側): 実ユーザー入力で active になったまま応答が来ず、その入力が古ければ
|
|
880
|
+
// idle に倒す。assistant 側 (tool_use 末尾) はここでは floor しない (長時間ツールの偽陰性を防ぐ)。
|
|
881
|
+
// assistant 側のクラッシュ判定は turnActiveForCwd が PID 生存 probe で別途行う (T2)。
|
|
840
882
|
if (
|
|
841
883
|
active &&
|
|
842
884
|
activeBy === "user" &&
|
|
843
885
|
activeSinceTs !== null &&
|
|
844
886
|
now - activeSinceTs > STALE_USER_TAIL_MS
|
|
845
887
|
) {
|
|
846
|
-
return false
|
|
888
|
+
return { active: false, activeBy: null, activeSinceTs: null }
|
|
847
889
|
}
|
|
848
|
-
return active
|
|
890
|
+
return { active, activeBy, activeSinceTs }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function scanTurnActive(text, now = Date.now()) {
|
|
894
|
+
return _scanTurnState(text, now).active
|
|
849
895
|
}
|
|
850
896
|
|
|
851
897
|
/** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
|
|
@@ -862,13 +908,27 @@ const _turnActiveMemo = new Map()
|
|
|
862
908
|
* 1. cwd → encoded dir → bound `sessionId` の jsonl (あれば最優先) / 無ければ mtime 最新を選ぶ
|
|
863
909
|
* 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
|
|
864
910
|
* 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
|
|
911
|
+
* 4. (T2) tool_use 末尾 active かつ STALE_TOOL_USE_MS 経過のとき paneAliveProbe で
|
|
912
|
+
* claude プロセス生存を確認 → 'dead' なら active=false に倒す (中断マーカー無し
|
|
913
|
+
* の crash を idle 化)。'alive' / 'unknown' は active 維持 (長時間ツール + tmux
|
|
914
|
+
* 失敗時の偽陰性ゼロ)。sessionName / paneAliveProbe が無ければ probe をスキップ。
|
|
865
915
|
*
|
|
866
916
|
* @param {string} cwd
|
|
867
917
|
* @param {string|null} [sessionId] その tmux ペインが bind しているセッション id。指定時はその
|
|
868
918
|
* jsonl を直接読み、cwd dir 内の別セッション/サブエージェント/headless jsonl の取り違えを防ぐ。
|
|
919
|
+
* @param {string|null} [sessionName] tmux ペイン名。指定時かつ paneAliveProbe ありで tool_use
|
|
920
|
+
* 末尾 active のクラッシュ判定を行う (T2)。
|
|
921
|
+
* @param {{ paneAliveProbe?: (name: string) => Promise<'alive'|'dead'|'unknown'>, now?: number }} [opts]
|
|
922
|
+
* paneAliveProbe: tmux pane_current_command から claude プロセス生存を 3 値で返す関数。
|
|
923
|
+
* 循環 import 回避のため呼び出し側 (main.mjs) から DI で渡す。テストではモックを渡す。
|
|
869
924
|
* @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
|
|
870
925
|
*/
|
|
871
|
-
export async function turnActiveForCwd(
|
|
926
|
+
export async function turnActiveForCwd(
|
|
927
|
+
cwd,
|
|
928
|
+
sessionId = null,
|
|
929
|
+
sessionName = null,
|
|
930
|
+
opts = {},
|
|
931
|
+
) {
|
|
872
932
|
if (!cwd) return null
|
|
873
933
|
const dirName = encodeCwdToDirName(cwd)
|
|
874
934
|
if (!dirName) return null
|
|
@@ -885,20 +945,43 @@ export async function turnActiveForCwd(cwd, sessionId = null) {
|
|
|
885
945
|
return memo.result
|
|
886
946
|
}
|
|
887
947
|
|
|
948
|
+
const now = typeof opts.now === "number" ? opts.now : Date.now()
|
|
949
|
+
|
|
888
950
|
// 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
|
|
889
951
|
// tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
|
|
890
952
|
// に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
|
|
891
953
|
const tail = await readTail(newest.fp, 256 * 1024)
|
|
892
|
-
let
|
|
954
|
+
let state = { active: false, activeBy: null, activeSinceTs: null }
|
|
893
955
|
if (tail != null) {
|
|
894
|
-
|
|
956
|
+
state = _scanTurnState(tail, now)
|
|
895
957
|
}
|
|
896
958
|
// tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
|
|
897
959
|
// 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
|
|
898
|
-
if (active === false) {
|
|
960
|
+
if (state.active === false) {
|
|
899
961
|
const full = await readOrNull(newest.fp)
|
|
900
|
-
if (full != null)
|
|
962
|
+
if (full != null) state = _scanTurnState(full, now)
|
|
901
963
|
}
|
|
964
|
+
|
|
965
|
+
let active = state.active
|
|
966
|
+
|
|
967
|
+
// T2: tool_use 末尾 active (assistant 由来) で、その記入から STALE_TOOL_USE_MS 経過していて、
|
|
968
|
+
// tmux pane_current_command から probe 可能なら、claude プロセス生存を確認する。
|
|
969
|
+
// 'dead' (シェル前景) → 中断マーカー無しの crash 確証 → active=false に倒す。
|
|
970
|
+
// 'alive' (claude 等) → 長時間ツール継続中 → active 維持 (偽陰性ゼロ)。
|
|
971
|
+
// 'unknown' (tmux 失敗 / 空出力) → 判定不能 → 安全側で active 維持 (偽陰性ゼロ)。
|
|
972
|
+
// sessionName 未指定 / probe 関数未指定の場合は本ロジックをスキップ (後方互換、SDK チャット保護)。
|
|
973
|
+
if (
|
|
974
|
+
active &&
|
|
975
|
+
state.activeBy === "assistant" &&
|
|
976
|
+
state.activeSinceTs !== null &&
|
|
977
|
+
now - state.activeSinceTs > STALE_TOOL_USE_MS &&
|
|
978
|
+
sessionName &&
|
|
979
|
+
typeof opts.paneAliveProbe === "function"
|
|
980
|
+
) {
|
|
981
|
+
const verdict = await opts.paneAliveProbe(sessionName)
|
|
982
|
+
if (verdict === "dead") active = false
|
|
983
|
+
}
|
|
984
|
+
|
|
902
985
|
_turnActiveMemo.set(newest.fp, {
|
|
903
986
|
mtimeMs: newest.mtimeMs,
|
|
904
987
|
size: newest.size,
|