@cocorograph/hub-agent 0.6.92 → 0.6.94
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 +141 -5
- package/src/proc-introspect.mjs +325 -0
- package/src/state.mjs +66 -3
- package/src/tmux.mjs +31 -5
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
recoverTuiInput,
|
|
71
71
|
removeWorktree as removeWorktreeDir,
|
|
72
72
|
resumeWithMessage,
|
|
73
|
+
setSessionMouse,
|
|
73
74
|
setTmuxGlobalEnv,
|
|
74
75
|
setTuiEffort,
|
|
75
76
|
setTuiModel,
|
|
@@ -84,6 +85,12 @@ import {
|
|
|
84
85
|
recordChatRateLimit,
|
|
85
86
|
} from "./usage.mjs"
|
|
86
87
|
import { clearChatSignal, getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
88
|
+
import {
|
|
89
|
+
ReadinessTracker,
|
|
90
|
+
busyChildCount,
|
|
91
|
+
getPanePid,
|
|
92
|
+
snapshotProcs,
|
|
93
|
+
} from "./proc-introspect.mjs"
|
|
87
94
|
import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
88
95
|
|
|
89
96
|
const logger = pino({ name: "hub-agent" })
|
|
@@ -581,17 +588,34 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
581
588
|
|
|
582
589
|
// 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
|
|
583
590
|
// browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
|
|
591
|
+
// ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
|
|
592
|
+
// 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
|
|
593
|
+
// Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
|
|
594
|
+
const readinessTracker = new ReadinessTracker()
|
|
584
595
|
const stateLoop = startStateLoop({
|
|
585
596
|
client,
|
|
586
597
|
plugins,
|
|
587
598
|
logger,
|
|
588
599
|
intervalMs: 5_000,
|
|
589
600
|
claudeBridge,
|
|
601
|
+
readinessTracker,
|
|
590
602
|
})
|
|
591
603
|
// bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
|
|
592
|
-
// に書き出す UserPromptSubmit / Stop の event を fs.watch で拾って WS push する。
|
|
604
|
+
// に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
|
|
593
605
|
// text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
|
|
594
|
-
|
|
606
|
+
// prompt_submit / stop で readinessTracker を arm し、ready の settle 検出に繋ぐ。
|
|
607
|
+
const sessionEventLoop = await startSessionEventWatcher({
|
|
608
|
+
client,
|
|
609
|
+
logger,
|
|
610
|
+
readinessTracker,
|
|
611
|
+
})
|
|
612
|
+
// arm 済みセッションの子プロセスを高速 poll し、baseline 復帰で event ファイルに 'ready'
|
|
613
|
+
// を書く (既存 watcher が session.event 'ready' を push)。アイドル時は ps を spawn しない。
|
|
614
|
+
const readinessLoop = startReadinessLoop({
|
|
615
|
+
logger,
|
|
616
|
+
tracker: readinessTracker,
|
|
617
|
+
intervalMs: 700,
|
|
618
|
+
})
|
|
595
619
|
|
|
596
620
|
// TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
|
|
597
621
|
// 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
|
|
@@ -659,6 +683,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
659
683
|
await runHookBroadcast(plugins, "onAgentStop", ctx)
|
|
660
684
|
stateLoop.stop()
|
|
661
685
|
sessionEventLoop?.stop?.()
|
|
686
|
+
readinessLoop?.stop?.()
|
|
662
687
|
tuiPermissionBridge.stop()
|
|
663
688
|
tuiViewerRegistry.stop()
|
|
664
689
|
jsonlLiveWatchers.stop()
|
|
@@ -831,7 +856,7 @@ async function readSessionEventFile(sessionName) {
|
|
|
831
856
|
* ディレクトリが無ければ作成し、起動直後に既存ファイルを 1 回読んで初期 push。
|
|
832
857
|
* 以降は変更検知のたびに該当ファイルを読み直して push する。
|
|
833
858
|
*/
|
|
834
|
-
async function startSessionEventWatcher({ client, logger }) {
|
|
859
|
+
async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
835
860
|
try {
|
|
836
861
|
await mkdir(SESSION_EVENTS_DIR, { recursive: true })
|
|
837
862
|
} catch (err) {
|
|
@@ -850,6 +875,21 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
850
875
|
const text = await readFile(full, "utf-8")
|
|
851
876
|
const data = JSON.parse(text)
|
|
852
877
|
if (!data || typeof data.event !== "string") return
|
|
878
|
+
// ターン境界 (prompt_submit / stop) で readiness tracker を arm し、子プロセスが
|
|
879
|
+
// baseline へ復帰したら 'ready' を出す土台にする。'ready' 自身では arm しない
|
|
880
|
+
// (readiness loop の書込み → 再 arm の無限ループを避ける)。prompt_submit は次ターンの
|
|
881
|
+
// ready を解禁するため emitted をリセットする。
|
|
882
|
+
if (readinessTracker) {
|
|
883
|
+
if (data.event === "prompt_submit") {
|
|
884
|
+
readinessTracker.arm(sessionName, { newTurn: true })
|
|
885
|
+
} else if (data.event === "stop" || data.event === "idle_hint") {
|
|
886
|
+
// stop: ターン論理終了。idle_hint: Notification(idle_prompt) フックの裏取り。
|
|
887
|
+
// どちらも arm するだけで、実際の 'ready' は proc 内省 (子プロセス baseline 復帰)
|
|
888
|
+
// が確定させる。これにより Notification が Stop フック完了前に発火しても、
|
|
889
|
+
// proc が clear するまで誤フラッシュしない (idle_hint→即 ready にはしない)。
|
|
890
|
+
readinessTracker.arm(sessionName)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
853
893
|
client.send({
|
|
854
894
|
type: "session.event",
|
|
855
895
|
session_name: sessionName,
|
|
@@ -891,6 +931,88 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
891
931
|
}
|
|
892
932
|
}
|
|
893
933
|
|
|
934
|
+
/**
|
|
935
|
+
* arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
|
|
936
|
+
* 高速 poll し、子プロセスが baseline へ復帰したエッジで event ファイルに 'ready' を書く。
|
|
937
|
+
* 既存の startSessionEventWatcher がそれを `session.event` 'ready' として push し、frontend は
|
|
938
|
+
* 「真に入力可能」になった瞬間として消灯 + キューフラッシュする (Stop フック中の誤フラッシュ
|
|
939
|
+
* 根治)。tracker.getBusy() は state loop が proc_busy レベルとして相乗せする。
|
|
940
|
+
*
|
|
941
|
+
* 負荷: アイドル (armed/busy なし) のときは ps を spawn しない。claude pid は TTL キャッシュ。
|
|
942
|
+
*/
|
|
943
|
+
function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
|
|
944
|
+
let stopped = false
|
|
945
|
+
const PANE_PID_TTL_MS = 30_000
|
|
946
|
+
const panePidByName = new Map() // name → {pid, at}
|
|
947
|
+
const unresolvedStreak = new Map() // name → 連続解決失敗回数 (SDK チャット等の掃除用)
|
|
948
|
+
|
|
949
|
+
// busy 子数のカウント基点は claude 本体 PID ではなく tmux の **pane_pid** にする。
|
|
950
|
+
// 理由: (1) クロスプラットフォーム安全 — claude を comm 名で同定する方式は Linux/WSL で
|
|
951
|
+
// claude が `node .../cli.js` として起動し comm が 'node' になり同定に失敗する。pane_pid
|
|
952
|
+
// 基点なら子孫を数えるだけで claude の同定が不要。(2) /clear 耐性 — claude PID は /clear で
|
|
953
|
+
// 変わるが pane_pid (ペインの shell) は不変なので再解決が要らない。baseline (rolling-min) が
|
|
954
|
+
// 「claude 本体 + 常駐 MCP」を吸収するので、基点が pane_pid でも busy=「一過性の子増加」で正しい。
|
|
955
|
+
const resolvePanePid = async (name, procMap) => {
|
|
956
|
+
const hit = panePidByName.get(name)
|
|
957
|
+
if (hit && procMap.has(hit.pid) && Date.now() - hit.at < PANE_PID_TTL_MS) {
|
|
958
|
+
return hit.pid
|
|
959
|
+
}
|
|
960
|
+
const panePid = await getPanePid(name)
|
|
961
|
+
if (panePid && procMap.has(panePid)) {
|
|
962
|
+
panePidByName.set(name, { pid: panePid, at: Date.now() })
|
|
963
|
+
return panePid
|
|
964
|
+
}
|
|
965
|
+
panePidByName.delete(name)
|
|
966
|
+
return null
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const tick = async () => {
|
|
970
|
+
if (stopped) return
|
|
971
|
+
try {
|
|
972
|
+
const names = []
|
|
973
|
+
for (const n of tracker.byName.keys()) {
|
|
974
|
+
if (tracker.isActive(n)) names.push(n)
|
|
975
|
+
}
|
|
976
|
+
if (names.length === 0) return
|
|
977
|
+
const procMap = await snapshotProcs()
|
|
978
|
+
if (procMap.size === 0) return // ps 失敗時は据え置き (spurious ready 防止)
|
|
979
|
+
for (const name of names) {
|
|
980
|
+
const root = await resolvePanePid(name, procMap)
|
|
981
|
+
if (!root) {
|
|
982
|
+
// 解決失敗は observe しない (count=0 を注入すると誤って ready が出るため)。
|
|
983
|
+
// SDK チャット等で tmux セッションが無いものは連続失敗で tracker から忘れる。
|
|
984
|
+
const k = (unresolvedStreak.get(name) ?? 0) + 1
|
|
985
|
+
if (k >= 8) {
|
|
986
|
+
tracker.forget(name)
|
|
987
|
+
unresolvedStreak.delete(name)
|
|
988
|
+
panePidByName.delete(name)
|
|
989
|
+
} else {
|
|
990
|
+
unresolvedStreak.set(name, k)
|
|
991
|
+
}
|
|
992
|
+
continue
|
|
993
|
+
}
|
|
994
|
+
unresolvedStreak.delete(name)
|
|
995
|
+
const count = busyChildCount(root, procMap)
|
|
996
|
+
const { ready } = tracker.observe(name, count)
|
|
997
|
+
if (ready) {
|
|
998
|
+
await writeSessionEventFile(name, "ready", Date.now())
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
logger?.warn({ err: err?.message }, "readiness loop tick failed")
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const ti = setInterval(tick, intervalMs)
|
|
1007
|
+
ti.unref?.()
|
|
1008
|
+
return {
|
|
1009
|
+
stop() {
|
|
1010
|
+
stopped = true
|
|
1011
|
+
clearInterval(ti)
|
|
1012
|
+
},
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
894
1016
|
/**
|
|
895
1017
|
* 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
|
|
896
1018
|
*
|
|
@@ -899,7 +1021,7 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
899
1021
|
* pty.exit 受信時に処理する)
|
|
900
1022
|
* - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
|
|
901
1023
|
*/
|
|
902
|
-
function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
1024
|
+
function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker }) {
|
|
903
1025
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
904
1026
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
905
1027
|
let stopped = false
|
|
@@ -968,19 +1090,25 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
968
1090
|
// 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
|
|
969
1091
|
// 即消灯するためのフラグ)。chat 信号で processing へ上書きされた場合は false。
|
|
970
1092
|
const stable = status !== "processing" && s.stable === true
|
|
1093
|
+
// プロセス内省由来の busy レベル (Stop フック/ツール実行中=true)。frontend は
|
|
1094
|
+
// これでペイン由来の早期消灯を抑止し、tool/Stop フック中もスピナーを保つ。
|
|
1095
|
+
// 高速 readiness loop が維持する値をそのまま相乗せする (この loop では計算しない)。
|
|
1096
|
+
const procBusy = readinessTracker ? readinessTracker.getBusy(s.session_name) : false
|
|
971
1097
|
const prev = lastByName.get(s.session_name)
|
|
972
1098
|
if (
|
|
973
1099
|
!prev ||
|
|
974
1100
|
prev.status !== status ||
|
|
975
1101
|
prev.context_pct !== contextPct ||
|
|
976
1102
|
prev.permission_mode !== permissionMode ||
|
|
977
|
-
prev.stable !== stable
|
|
1103
|
+
prev.stable !== stable ||
|
|
1104
|
+
prev.proc_busy !== procBusy
|
|
978
1105
|
) {
|
|
979
1106
|
lastByName.set(s.session_name, {
|
|
980
1107
|
status,
|
|
981
1108
|
context_pct: contextPct,
|
|
982
1109
|
permission_mode: permissionMode,
|
|
983
1110
|
stable,
|
|
1111
|
+
proc_busy: procBusy,
|
|
984
1112
|
})
|
|
985
1113
|
client.send({
|
|
986
1114
|
type: "session.state",
|
|
@@ -989,6 +1117,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
989
1117
|
context_pct: contextPct,
|
|
990
1118
|
permission_mode: permissionMode,
|
|
991
1119
|
stable,
|
|
1120
|
+
proc_busy: procBusy,
|
|
992
1121
|
})
|
|
993
1122
|
}
|
|
994
1123
|
}
|
|
@@ -1222,6 +1351,13 @@ async function dispatch(msg, ctx) {
|
|
|
1222
1351
|
cols: msg.cols,
|
|
1223
1352
|
rows: msg.rows,
|
|
1224
1353
|
})
|
|
1354
|
+
// 接続端末の種別に応じて tmux mouse mode を出し分ける (スマホ=on /
|
|
1355
|
+
// デスクトップ=off)。boolean のときだけ上書きし、未指定 (旧フロント) は
|
|
1356
|
+
// createSession の既定値 (on) を尊重する。fire-and-forget で pty.ready を
|
|
1357
|
+
// 妨げない (失敗は setSessionMouse 内で warn 済み)。
|
|
1358
|
+
if (typeof msg.mouse === "boolean" && msg.session_name) {
|
|
1359
|
+
setSessionMouse(msg.session_name, msg.mouse, { logger: ctx.logger })
|
|
1360
|
+
}
|
|
1225
1361
|
ctx.client.send({
|
|
1226
1362
|
type: "pty.ready",
|
|
1227
1363
|
stream_id,
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* プロセス内省による「入力可能 (ready-for-input)」判定。
|
|
3
|
+
*
|
|
4
|
+
* 背景 / なぜ画面スクレイプでなくプロセスを見るのか:
|
|
5
|
+
* Cockpit の TUI チャットは tmux 上の対話 `claude` を hub-agent (= 同一ホスト) が
|
|
6
|
+
* 仲介する。生成中/アイドルの判定はこれまで capture-pane のスピナー文字列 (state.mjs)
|
|
7
|
+
* に一本依存していたが、これは「Stop フック実行中」を観測できない致命的な穴がある:
|
|
8
|
+
* モデルのターンが終わると claude TUI はスピナー行を消し、アイドルの `❯` プロンプトを
|
|
9
|
+
* 描く。しかし組織のグローバル Stop フック (memory_update.py 等) はその後ろでまだ
|
|
10
|
+
* 走っており、入力を受け付けられる状態ではない。capture-pane はアイドルに見えるため、
|
|
11
|
+
* frontend がキューを早期フラッシュ → ビジーな TUI に paste → 内部キュー行きで
|
|
12
|
+
* バブルと jsonl の位置がズレる事故になっていた。
|
|
13
|
+
*
|
|
14
|
+
* Claude Code は「Stop フックチェーン完了 = 入力可能」を知らせるフックイベントを
|
|
15
|
+
* 持たず、Stop フックは並列実行のため「最後のフック」で完了を知ることもできない
|
|
16
|
+
* (claude-code-guide で確認, 2026-06-18)。そこで、hub-agent が claude と同一ホストに
|
|
17
|
+
* 居る利点を使い、claude プロセスの子プロセスを直接観測する:
|
|
18
|
+
* - Stop フック実行中 → フックスクリプト (python3 等) が claude の子として生存
|
|
19
|
+
* - ツール実行中 (Bash) → ツールが claude の子として生存
|
|
20
|
+
* - 真のアイドル(入力待ち) → 一過性の子は無い (常駐 MCP stdio サーバーを除く)
|
|
21
|
+
*
|
|
22
|
+
* 常駐 MCP の除外 (実機観察 2026-06-18):
|
|
23
|
+
* アイドルの claude は安定して 2 個の stdio MCP 子 (chrome-devtools-mcp / pencil) を
|
|
24
|
+
* 持つ。一過性の tool/hook はこれに上乗せされる。よって busy = 「子孫数 > 直近の
|
|
25
|
+
* 最小子孫数 (rolling-min ベースライン)」で判定する。ベースラインは自然に常駐 MCP の
|
|
26
|
+
* 数に収束し、明示的な MCP 同定は不要。
|
|
27
|
+
* 例外: `caffeinate` は Claude Code がスリープ抑止に spawn する -t 300 の 5 分常駐で、
|
|
28
|
+
* ターン後も居残るため busy 判定の子数から除外する (denylist)。
|
|
29
|
+
*/
|
|
30
|
+
import { execFile } from "node:child_process"
|
|
31
|
+
import { promisify } from "node:util"
|
|
32
|
+
|
|
33
|
+
const execFileP = promisify(execFile)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 「busy とみなさない」background helper の comm 基底名 denylist。
|
|
37
|
+
* - caffeinate: 生成中のスリープ抑止に spawn される 5 分常駐 (-t 300)。ターン後も残るため
|
|
38
|
+
* 除外しないと proc_busy がターン後 5 分間 true のままになり ready が遅延する。
|
|
39
|
+
*/
|
|
40
|
+
const BUSY_EXCLUDE_COMM = new Set(["caffeinate"])
|
|
41
|
+
|
|
42
|
+
/** パス文字列の基底名 (最後の / 以降)。空なら空文字。 */
|
|
43
|
+
export function commBasename(comm) {
|
|
44
|
+
if (!comm) return ""
|
|
45
|
+
const s = String(comm).trim()
|
|
46
|
+
const i = s.lastIndexOf("/")
|
|
47
|
+
return i >= 0 ? s.slice(i + 1) : s
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `ps -axo pid=,ppid=,comm=` を 1 回 spawn してプロセス表を返す。
|
|
52
|
+
* comm はパスにスペースを含み得る (例: ".../Claude Helper (Renderer)") ため、
|
|
53
|
+
* 行頭 2 トークンを pid/ppid、残り全部を comm として扱う。
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<Map<number, {ppid: number, comm: string}>>}
|
|
56
|
+
*/
|
|
57
|
+
export async function snapshotProcs({ psBin = "ps", execFileImpl = execFileP } = {}) {
|
|
58
|
+
const map = new Map()
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileImpl(psBin, ["-axo", "pid=,ppid=,comm="])
|
|
61
|
+
for (const line of stdout.split("\n")) {
|
|
62
|
+
const t = line.trim()
|
|
63
|
+
if (!t) continue
|
|
64
|
+
const m = t.match(/^(\d+)\s+(\d+)\s+(.*)$/)
|
|
65
|
+
if (!m) continue
|
|
66
|
+
const pid = Number(m[1])
|
|
67
|
+
const ppid = Number(m[2])
|
|
68
|
+
if (!Number.isFinite(pid)) continue
|
|
69
|
+
map.set(pid, { ppid, comm: m[3] || "" })
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// ps 失敗時は空 Map (呼び出し側は busy 判定を据え置く)。
|
|
73
|
+
}
|
|
74
|
+
return map
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** procMap から ppid → 子 pid[] の逆引きを作る。 */
|
|
78
|
+
function buildChildren(procMap) {
|
|
79
|
+
const children = new Map()
|
|
80
|
+
for (const [pid, info] of procMap) {
|
|
81
|
+
const arr = children.get(info.ppid)
|
|
82
|
+
if (arr) arr.push(pid)
|
|
83
|
+
else children.set(info.ppid, [pid])
|
|
84
|
+
}
|
|
85
|
+
return children
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* rootPid の子孫 pid を BFS で集める (root 自身は含めない)。循環は visited で防ぐ。
|
|
90
|
+
* @returns {number[]}
|
|
91
|
+
*/
|
|
92
|
+
export function descendantsOf(rootPid, procMap, children = buildChildren(procMap)) {
|
|
93
|
+
const out = []
|
|
94
|
+
const visited = new Set([rootPid])
|
|
95
|
+
let frontier = children.get(rootPid) || []
|
|
96
|
+
while (frontier.length) {
|
|
97
|
+
const next = []
|
|
98
|
+
for (const pid of frontier) {
|
|
99
|
+
if (visited.has(pid)) continue
|
|
100
|
+
visited.add(pid)
|
|
101
|
+
out.push(pid)
|
|
102
|
+
const kids = children.get(pid)
|
|
103
|
+
if (kids) next.push(...kids)
|
|
104
|
+
}
|
|
105
|
+
frontier = next
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* tmux session の pane_pid を取得する (失敗時 null)。
|
|
112
|
+
*/
|
|
113
|
+
export async function getPanePid(sessionName, { tmuxBin = "tmux", execFileImpl = execFileP } = {}) {
|
|
114
|
+
if (!sessionName || /[/\\]/.test(sessionName)) return null
|
|
115
|
+
try {
|
|
116
|
+
const { stdout } = await execFileImpl(tmuxBin, [
|
|
117
|
+
"display-message",
|
|
118
|
+
"-p",
|
|
119
|
+
"-t",
|
|
120
|
+
`${sessionName}:`,
|
|
121
|
+
"-F",
|
|
122
|
+
"#{pane_pid}",
|
|
123
|
+
])
|
|
124
|
+
const n = Number(stdout.trim())
|
|
125
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* pane_pid から対話 claude 本体の pid を解決する。
|
|
133
|
+
* cockpit の tmux ペインは shell (pane_pid) の子として `claude` を起動するため、
|
|
134
|
+
* pane_pid 自身か、その子孫のうち comm 基底名が 'claude' のものを返す。
|
|
135
|
+
* @returns {number|null}
|
|
136
|
+
*/
|
|
137
|
+
export function resolveClaudePid(panePid, procMap) {
|
|
138
|
+
if (!panePid) return null
|
|
139
|
+
const self = procMap.get(panePid)
|
|
140
|
+
if (self && commBasename(self.comm) === "claude") return panePid
|
|
141
|
+
const children = buildChildren(procMap)
|
|
142
|
+
for (const pid of descendantsOf(panePid, procMap, children)) {
|
|
143
|
+
const info = procMap.get(pid)
|
|
144
|
+
if (info && commBasename(info.comm) === "claude") return pid
|
|
145
|
+
}
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* rootPid (通常は tmux の pane_pid。claude pid でも可) の「busy を示す」子孫数を数える
|
|
151
|
+
* (denylist の background helper を除外)。常駐 MCP / claude 本体は除外しない
|
|
152
|
+
* (rolling-min ベースラインが吸収する)。一過性の tool/hook 子だけがカウントを押し上げる。
|
|
153
|
+
*/
|
|
154
|
+
export function busyChildCount(rootPid, procMap) {
|
|
155
|
+
if (!rootPid) return 0
|
|
156
|
+
const children = buildChildren(procMap)
|
|
157
|
+
let n = 0
|
|
158
|
+
for (const pid of descendantsOf(rootPid, procMap, children)) {
|
|
159
|
+
const info = procMap.get(pid)
|
|
160
|
+
if (!info) continue
|
|
161
|
+
if (BUSY_EXCLUDE_COMM.has(commBasename(info.comm))) continue
|
|
162
|
+
n += 1
|
|
163
|
+
}
|
|
164
|
+
return n
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* セッション毎の「resident (idle) ベースライン」で busy / ready エッジを判定する純粋ステート。
|
|
169
|
+
*
|
|
170
|
+
* ❗ 旧実装の rolling-min (直近 windowMs の最小 count) は致命的なドリフトがあった:
|
|
171
|
+
* - 長時間ツール (>windowMs) で idle サンプルが窓から押し出され baseline がツール水準まで
|
|
172
|
+
* 上昇 → busy=false → 走行中に誤 ready (= 誤フラッシュ)。
|
|
173
|
+
* - 遅延起動の常駐 MCP で真の idle が上がっても min は古い低値に張り付き busy 固着。
|
|
174
|
+
* これを避けるため baseline は「ターン中は凍結 (下げのみ許可)、ターン間 idle で学習」する:
|
|
175
|
+
* - 非 armed (ターン間 idle / ready 直後の grace) の observe: baseline = count を学習する。
|
|
176
|
+
* 遅延起動 MCP 等で resident 水準が上がっても次ターンの baseline に反映される。
|
|
177
|
+
* - armed (ターン中) の observe: baseline は **上げない** (一過性ツールが新 idle に化けるのを
|
|
178
|
+
* 防ぐ)。より低い idle 床を観測したときだけ下げる (bootstrap 汚染の自己補正)。
|
|
179
|
+
* busy = count > baseline。よって長時間ツールでも count は baseline を超え続け busy のまま。
|
|
180
|
+
* - ready = stopSeen かつ busy=false が debounceSamples 連続したエッジで 1 回だけ true。
|
|
181
|
+
* ❗ ready を **stopSeen ゲート** する (= stop/idle_hint を受けた後のみ)。prompt_submit 直後は
|
|
182
|
+
* UserPromptSubmit フックの子が走っており初回 observe が baseline を汚染し得るため、ターン中の
|
|
183
|
+
* 誤 ready (event ファイル上書き → サイドバードット誤反転 / 本物 ready の emitted ラッチ毒)
|
|
184
|
+
* を構造的に防ぐ。ready は「ターン終了後の入力可能」のみ意味を持つ。
|
|
185
|
+
* - 安全弁 maxActiveSamples: armed のまま settle しない (遅延常駐子で busy 永続 / stop 取り
|
|
186
|
+
* こぼし) と isActive=true が続き ps spawn リーク + proc_busy 固着になる。上限で baseline を
|
|
187
|
+
* 現値へ再学習し disarm する (frontend は 120s net が畳むので表示影響なし。次ターンで再 arm)。
|
|
188
|
+
*
|
|
189
|
+
* arm(name): prompt_submit / stop / idle_hint で呼ぶ。newTurn (prompt_submit) は emitted を
|
|
190
|
+
* リセットし stopSeen=false に戻す。stop/idle_hint は stopSeen=true で ready 発火を解禁する。
|
|
191
|
+
* busy が一度も観測されなくても (Stop フックが poll 間隔より速い場合)、stop 後なら baseline
|
|
192
|
+
* 復帰の debounce 後に ready を出す。
|
|
193
|
+
*/
|
|
194
|
+
export class ReadinessTracker {
|
|
195
|
+
constructor({
|
|
196
|
+
debounceSamples = 2,
|
|
197
|
+
graceSamples = 4,
|
|
198
|
+
// armed のまま settle しない上限サンプル数 (≈ 200×700ms ≈ 140s)。遅延常駐子で busy が
|
|
199
|
+
// 解けない / stop 取りこぼしで idle のまま等で armed が永続すると、isActive=true が続いて
|
|
200
|
+
// readiness loop が ps を 700ms 毎に spawn し続けるリーク + proc_busy 固着になる。上限で
|
|
201
|
+
// baseline を現値へ再学習して disarm し、リーク/固着を解消する。frontend は 120s net が
|
|
202
|
+
// スピナーを既に畳んでいるため表示影響は無い (次ターンで再 arm)。
|
|
203
|
+
maxActiveSamples = 200,
|
|
204
|
+
now = () => Date.now(),
|
|
205
|
+
} = {}) {
|
|
206
|
+
this.debounceSamples = Math.max(1, debounceSamples)
|
|
207
|
+
// ready 後しばらく観測を続けて idle baseline を学習する tick 数 (遅延起動 MCP 反映用)。
|
|
208
|
+
this.graceSamples = Math.max(0, graceSamples)
|
|
209
|
+
this.maxActiveSamples = Math.max(1, maxActiveSamples)
|
|
210
|
+
this._now = now
|
|
211
|
+
/** @type {Map<string, {baseline:number|null, armed:boolean, busy:boolean, clearStreak:number, emitted:boolean, grace:number, stopSeen:boolean, activeStreak:number}>} */
|
|
212
|
+
this.byName = new Map()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_get(name) {
|
|
216
|
+
let s = this.byName.get(name)
|
|
217
|
+
if (!s) {
|
|
218
|
+
s = {
|
|
219
|
+
baseline: null,
|
|
220
|
+
armed: false,
|
|
221
|
+
busy: false,
|
|
222
|
+
clearStreak: 0,
|
|
223
|
+
emitted: false,
|
|
224
|
+
grace: 0,
|
|
225
|
+
stopSeen: false,
|
|
226
|
+
activeStreak: 0,
|
|
227
|
+
}
|
|
228
|
+
this.byName.set(name, s)
|
|
229
|
+
}
|
|
230
|
+
return s
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* prompt_submit / stop / idle_hint でターン進行を宣言する。
|
|
235
|
+
* clearStreak を 0 に戻すことで、arm 以降に debounceSamples 回連続でアイドルを観測して
|
|
236
|
+
* 初めて ready を出す = stop 直後に Stop フックの子が spawn し始めるレースを吸収する。
|
|
237
|
+
*
|
|
238
|
+
* newTurn (prompt_submit): 次ターン開始。emitted リセットで ready を解禁し、stopSeen=false に
|
|
239
|
+
* 戻す (モデルのターンはまだ終わっていない)。baseline はターン間 idle 学習値を引き継ぐ。
|
|
240
|
+
* それ以外 (stop / idle_hint): モデルのターンが終わった合図。stopSeen=true にして ready 発火を
|
|
241
|
+
* 解禁する。ready を **stopSeen ゲート**することで、prompt_submit 直後の bootstrap 汚染
|
|
242
|
+
* (UserPromptSubmit フックの子を初回 observe が拾い baseline が膨らむ) でターン中に誤 ready が
|
|
243
|
+
* 出て event ファイルを上書きする退行を防ぐ (ready は「ターン終了後の入力可能」のみ意味を持つ)。
|
|
244
|
+
*/
|
|
245
|
+
arm(name, { newTurn = false } = {}) {
|
|
246
|
+
const s = this._get(name)
|
|
247
|
+
s.armed = true
|
|
248
|
+
s.clearStreak = 0
|
|
249
|
+
s.activeStreak = 0
|
|
250
|
+
s.grace = 0
|
|
251
|
+
if (newTurn) {
|
|
252
|
+
s.emitted = false
|
|
253
|
+
s.stopSeen = false
|
|
254
|
+
} else {
|
|
255
|
+
s.stopSeen = true
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @returns {{busy: boolean, ready: boolean}} */
|
|
260
|
+
observe(name, count) {
|
|
261
|
+
const s = this._get(name)
|
|
262
|
+
if (s.baseline === null) s.baseline = count // bootstrap (初回 ≈ idle)
|
|
263
|
+
if (!s.armed) {
|
|
264
|
+
// ターン間 idle / ready 直後の grace: resident baseline を学習 (上下とも追従)。
|
|
265
|
+
s.busy = false
|
|
266
|
+
s.baseline = count
|
|
267
|
+
if (s.grace > 0) s.grace -= 1
|
|
268
|
+
return { busy: false, ready: false }
|
|
269
|
+
}
|
|
270
|
+
s.activeStreak += 1
|
|
271
|
+
// 安全弁: armed のまま settle しない上限 (≈140s)。baseline を現値へ再学習し disarm して
|
|
272
|
+
// ps spawn リーク / proc_busy 固着を解消する (遅延常駐子で busy が永続するケース等)。
|
|
273
|
+
// ⚠️ ここは baseline=count (現 busy 値への引き上げ) であって Math.min ではない。遅延常駐子で
|
|
274
|
+
// 永続的に上がった count を新 idle として吸収するため上げる必要がある (Math.min にすると
|
|
275
|
+
// 「遅延常駐子で busy 固着」HIGH バグが再発する)。代償として、cap が長時間ツールで発火した
|
|
276
|
+
// 場合は次ターン頭で baseline が高めに残り proc_busy を一時的に過少報告し得る (LOW: スピナーが
|
|
277
|
+
// ツール中に早めに消えるだけで、ready/フラッシュ経路は無傷。次の idle 観測で line の下げ学習が
|
|
278
|
+
// 自己補正する)。cap 自体が 140s 無 settle という稀な劣化状態の後始末なので許容する。
|
|
279
|
+
if (s.activeStreak >= this.maxActiveSamples) {
|
|
280
|
+
s.baseline = count
|
|
281
|
+
s.busy = false
|
|
282
|
+
s.armed = false
|
|
283
|
+
s.grace = this.graceSamples
|
|
284
|
+
s.clearStreak = 0
|
|
285
|
+
s.activeStreak = 0
|
|
286
|
+
return { busy: false, ready: false }
|
|
287
|
+
}
|
|
288
|
+
// ターン中: baseline は上げない (一過性ツールを新 idle に化けさせない)。より低い床は学習。
|
|
289
|
+
if (count < s.baseline) s.baseline = count
|
|
290
|
+
const busy = count > s.baseline
|
|
291
|
+
s.busy = busy
|
|
292
|
+
if (busy) {
|
|
293
|
+
s.emitted = false
|
|
294
|
+
s.clearStreak = 0
|
|
295
|
+
return { busy: true, ready: false }
|
|
296
|
+
}
|
|
297
|
+
s.clearStreak += 1
|
|
298
|
+
let ready = false
|
|
299
|
+
// ready は stopSeen (= stop/idle_hint 受信済み = モデルのターン終了) のときだけ発火する。
|
|
300
|
+
if (s.stopSeen && s.clearStreak >= this.debounceSamples) {
|
|
301
|
+
if (!s.emitted) {
|
|
302
|
+
ready = true
|
|
303
|
+
s.emitted = true
|
|
304
|
+
}
|
|
305
|
+
// ready 済み or 今出した → armed を畳んで grace へ (idle baseline 学習を続ける)。
|
|
306
|
+
s.armed = false
|
|
307
|
+
s.grace = this.graceSamples
|
|
308
|
+
}
|
|
309
|
+
return { busy, ready }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
getBusy(name) {
|
|
313
|
+
return this.byName.get(name)?.busy ?? false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** readiness loop が観測対象に含めるべきか (armed / busy / grace 中)。 */
|
|
317
|
+
isActive(name) {
|
|
318
|
+
const s = this.byName.get(name)
|
|
319
|
+
return !!s && (s.armed || s.busy || s.grace > 0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
forget(name) {
|
|
323
|
+
this.byName.delete(name)
|
|
324
|
+
}
|
|
325
|
+
}
|
package/src/state.mjs
CHANGED
|
@@ -46,11 +46,13 @@ export function invalidateSessionCache(sessionName) {
|
|
|
46
46
|
_captureCache.clear()
|
|
47
47
|
_cwdCache.clear()
|
|
48
48
|
_statusGate.clear()
|
|
49
|
+
_spinnerFreezeByName.clear()
|
|
49
50
|
return
|
|
50
51
|
}
|
|
51
52
|
_captureCache.delete(sessionName)
|
|
52
53
|
_cwdCache.delete(sessionName)
|
|
53
54
|
_statusGate.delete(sessionName)
|
|
55
|
+
_spinnerFreezeByName.delete(sessionName)
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -185,6 +187,15 @@ function _belowIsOnlyChrome(lines, i) {
|
|
|
185
187
|
* ペイン最下部の入力欄直上にあるライブのスピナーだけを生成中と判定する (誤点灯防止)。
|
|
186
188
|
*/
|
|
187
189
|
function detectWorkingSpinner(text) {
|
|
190
|
+
return workingSpinnerLine(text) !== null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 作業スピナー行そのもの (経過タイマー込み) を返す。検出ロジックは detectWorkingSpinner と
|
|
195
|
+
* 同一だが、frozen 判定 (下記) でライブタイマーの変化を見るために行文字列を取り出す。
|
|
196
|
+
* 見つからなければ null。
|
|
197
|
+
*/
|
|
198
|
+
function workingSpinnerLine(text) {
|
|
188
199
|
const lines = text.split("\n")
|
|
189
200
|
const start = Math.max(0, lines.length - 12)
|
|
190
201
|
for (let i = lines.length - 1; i >= start; i--) {
|
|
@@ -192,9 +203,50 @@ function detectWorkingSpinner(text) {
|
|
|
192
203
|
const isSpinner = SPINNER_LINE_RE.test(line)
|
|
193
204
|
const isTokenFooter =
|
|
194
205
|
/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)
|
|
195
|
-
if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i))
|
|
206
|
+
if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) {
|
|
207
|
+
return line.trim()
|
|
208
|
+
}
|
|
196
209
|
}
|
|
197
|
-
return
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 「スピナーは画面に在るが、アニメ (経過タイマー) が止まっている = 凍結」を検出する。
|
|
215
|
+
*
|
|
216
|
+
* claude が異常終了 / ハングするとスピナー行が経過タイマー付きで画面に残るが、タイマーは
|
|
217
|
+
* 進まない。capture-pane の単フレーム判定 (detectWorkingSpinner) はこれを「生成中」と誤読し、
|
|
218
|
+
* 三点リーダーが永久に消えない (ユーザー報告の主症状の一つ)。プロセス内省は claude 自体が
|
|
219
|
+
* 死んでいると ready を出せない (子プロセスも無いが arm もされない) ため、この frozen 判定が
|
|
220
|
+
* 画面側の最後の保険になる。
|
|
221
|
+
*
|
|
222
|
+
* ライブのスピナーはタイマーが毎秒進むため、state loop の各 tick (5s 間隔・capture は都度
|
|
223
|
+
* 新規) では必ず行文字列が変化する。よって「同一スピナー行が SPINNER_FREEZE_CONFIRM_MS 以上
|
|
224
|
+
* 不変」なら凍結とみなす。閾値は capture キャッシュ TTL (2.5s) と state loop 周期 (5s) を跨ぐ
|
|
225
|
+
* 6s に取り、ライブタイマーの 1 サンプル取りこぼしでは誤判定しないようにする。
|
|
226
|
+
*/
|
|
227
|
+
const SPINNER_FREEZE_CONFIRM_MS = Number(
|
|
228
|
+
process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 6000,
|
|
229
|
+
)
|
|
230
|
+
/** @type {Map<string, {line: string, at: number}>} session名 → 最初にその行を見た時刻 */
|
|
231
|
+
const _spinnerFreezeByName = new Map()
|
|
232
|
+
|
|
233
|
+
export function isSpinnerFrozen(sessionName, text, now = Date.now()) {
|
|
234
|
+
const line = workingSpinnerLine(text)
|
|
235
|
+
if (!line) {
|
|
236
|
+
_spinnerFreezeByName.delete(sessionName)
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
const prev = _spinnerFreezeByName.get(sessionName)
|
|
240
|
+
if (!prev || prev.line !== line) {
|
|
241
|
+
_spinnerFreezeByName.set(sessionName, { line, at: now })
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
return now - prev.at >= SPINNER_FREEZE_CONFIRM_MS
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** テスト用: 凍結判定の状態を空にする。 */
|
|
248
|
+
export function _resetSpinnerFreeze() {
|
|
249
|
+
_spinnerFreezeByName.clear()
|
|
198
250
|
}
|
|
199
251
|
|
|
200
252
|
export function detectStatusFromText(text) {
|
|
@@ -515,7 +567,18 @@ function _computeStable(sessionName, status, text) {
|
|
|
515
567
|
|
|
516
568
|
export async function detectSessionState(sessionName, opts = {}) {
|
|
517
569
|
const text = await capturePane(sessionName, opts)
|
|
518
|
-
|
|
570
|
+
let defaultStatus = detectStatusFromText(text)
|
|
571
|
+
// 凍結スピナー対策: スピナーが画面に在るが経過タイマーが進んでいない (claude 異常終了/ハング)
|
|
572
|
+
// 場合、生成中ではないので processing を取り下げる。"esc to interrupt" がある間は別のライブ
|
|
573
|
+
// シグナルなので対象外。frozen と判定したらスピナーを無視した素の判定 (❯→waiting / else idle)。
|
|
574
|
+
if (
|
|
575
|
+
defaultStatus === "processing" &&
|
|
576
|
+
!/esc to interrupt/i.test(text) &&
|
|
577
|
+
isSpinnerFrozen(sessionName, text)
|
|
578
|
+
) {
|
|
579
|
+
defaultStatus =
|
|
580
|
+
/❯\s/.test(text) || /^>\s/m.test(text) ? "waiting" : "idle"
|
|
581
|
+
}
|
|
519
582
|
const defaultContextPct = detectContextPctFromText(text)
|
|
520
583
|
const defaultPermissionMode = detectPermissionModeFromText(text)
|
|
521
584
|
|
package/src/tmux.mjs
CHANGED
|
@@ -715,11 +715,16 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
715
715
|
// has-session が非 0 = セッション無し
|
|
716
716
|
}
|
|
717
717
|
await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
// session-scoped、`-t <session>` 指定で他 session への副作用なし。
|
|
721
|
-
//
|
|
722
|
-
//
|
|
718
|
+
// 既定では mouse mode を ON にする (スマホの touch swipe → SGR wheel escape を
|
|
719
|
+
// tmux が copy-mode スクロールとして拾えるようにするため)。tmux 2.1+ で `mouse`
|
|
720
|
+
// option は session-scoped、`-t <session>` 指定で他 session への副作用なし。
|
|
721
|
+
//
|
|
722
|
+
// ⚠️ ただしこれは「初期既定値」にすぎない。実際の値は接続してきた端末に応じて
|
|
723
|
+
// pty.attach 時に setSessionMouse() で上書きされる: デスクトップ attach は off
|
|
724
|
+
// (= マウスのドラッグを xterm のローカル選択にして、コピー奪取・button release
|
|
725
|
+
// 取りこぼしによる操作不能を防ぐ。0.5.11 で全 session を mouse on にしたことが
|
|
726
|
+
// デスクトップのドラッグ選択を壊した回帰の修正)。スマホ attach は on のまま。
|
|
727
|
+
// 旧フロント (mouse フィールド未送信) はこの既定値 on がそのまま使われる。
|
|
723
728
|
try {
|
|
724
729
|
await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
|
|
725
730
|
} catch (err) {
|
|
@@ -764,6 +769,27 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
764
769
|
}
|
|
765
770
|
}
|
|
766
771
|
|
|
772
|
+
/**
|
|
773
|
+
* 指定 session の tmux mouse mode を on/off する。pty.attach 時に接続端末の
|
|
774
|
+
* 種別に応じて呼ぶ (デスクトップ=off / スマホ=on)。失敗してもセッションの
|
|
775
|
+
* 基本機能は動くので warn のみで握り潰す。
|
|
776
|
+
* @param {string} name session 名
|
|
777
|
+
* @param {boolean} on true=mouse on / false=mouse off
|
|
778
|
+
* @param {{logger?: any, tmuxBin?: string}} [opts]
|
|
779
|
+
*/
|
|
780
|
+
export async function setSessionMouse(name, on, opts = {}) {
|
|
781
|
+
if (!name) return
|
|
782
|
+
const value = on ? "on" : "off"
|
|
783
|
+
try {
|
|
784
|
+
await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", value])
|
|
785
|
+
} catch (err) {
|
|
786
|
+
opts.logger?.warn?.(
|
|
787
|
+
{ session: name, mouse: value, err: err?.message || String(err) },
|
|
788
|
+
"tmux set-option mouse failed",
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
767
793
|
/** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
|
|
768
794
|
* Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
|
|
769
795
|
export function isSafeSessionId(sessionId) {
|