@cocorograph/hub-agent 0.7.4 → 0.7.6
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 +188 -8
- package/src/proc-introspect.mjs +6 -0
- package/src/tmux.mjs +86 -6
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
recoverTuiInput,
|
|
75
75
|
removeWorktree as removeWorktreeDir,
|
|
76
76
|
resumeWithMessage,
|
|
77
|
+
pasteToSessionByName,
|
|
77
78
|
setSessionMouse,
|
|
78
79
|
setTmuxGlobalEnv,
|
|
79
80
|
setTuiEffort,
|
|
@@ -599,6 +600,41 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
599
600
|
// claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
|
|
600
601
|
// respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
|
|
601
602
|
ctx.readinessTracker = readinessTracker
|
|
603
|
+
// 【+新規 defer — 構造版 root 2026-06-22】busy (ターン進行中) のペインへ来た fresh (+新規) は
|
|
604
|
+
// respawn-pane -k で殺さず保留し、ready (ターン終了) で 1 回だけ実行する。
|
|
605
|
+
// session_name → {cwd, request_id, at, model, permissionMode}。
|
|
606
|
+
const pendingFreshRespawn = new Map()
|
|
607
|
+
ctx.pendingFreshRespawn = pendingFreshRespawn
|
|
608
|
+
// fresh respawn のスロットル: idle ペインへの remount storm (再マウント毎に新 request_id) で
|
|
609
|
+
// fresh が連打され new セッションを乱造するのを防ぐ。session_name → epoch ms。
|
|
610
|
+
const lastFreshRespawnAt = new Map()
|
|
611
|
+
ctx.lastFreshRespawnAt = lastFreshRespawnAt
|
|
612
|
+
// ready エッジで呼ばれ、保留中の +新規 を実行する (busy が解けたので非破壊)。
|
|
613
|
+
const flushDeferredFreshRespawn = async (name) => {
|
|
614
|
+
const pending = pendingFreshRespawn.get(name)
|
|
615
|
+
if (!pending) return
|
|
616
|
+
pendingFreshRespawn.delete(name)
|
|
617
|
+
if (Date.now() - pending.at > PENDING_FRESH_TTL_MS) {
|
|
618
|
+
logger.info({ session: name }, "tui rebind: deferred +new expired before ready, dropped")
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
const rebind = await rebindClaudeSession(name, null, {
|
|
623
|
+
cwd: pending.cwd,
|
|
624
|
+
model: pending.model || "",
|
|
625
|
+
permissionMode: pending.permissionMode || "",
|
|
626
|
+
fresh: true,
|
|
627
|
+
busy: false,
|
|
628
|
+
logger,
|
|
629
|
+
})
|
|
630
|
+
if (rebind.ok) {
|
|
631
|
+
lastFreshRespawnAt.set(name, Date.now())
|
|
632
|
+
logger.info({ session: name }, "tui rebind: executed deferred +new after ready")
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
logger.warn({ session: name, err: err?.message }, "deferred +new respawn failed")
|
|
636
|
+
}
|
|
637
|
+
}
|
|
602
638
|
const stateLoop = startStateLoop({
|
|
603
639
|
client,
|
|
604
640
|
plugins,
|
|
@@ -622,6 +658,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
622
658
|
logger,
|
|
623
659
|
tracker: readinessTracker,
|
|
624
660
|
intervalMs: 700,
|
|
661
|
+
onReady: flushDeferredFreshRespawn,
|
|
625
662
|
})
|
|
626
663
|
|
|
627
664
|
// TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
|
|
@@ -950,16 +987,51 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
950
987
|
}
|
|
951
988
|
}
|
|
952
989
|
|
|
990
|
+
/**
|
|
991
|
+
* 【構造版 root 2026-06-22】idle ペインへの fresh remount storm を 1 回へ collapse する閾値。
|
|
992
|
+
* 直近 fresh respawn からこの時間内の同一セッションへの fresh は idempotent な再送とみなす。
|
|
993
|
+
*/
|
|
994
|
+
const FRESH_RESPAWN_THROTTLE_MS = 8_000
|
|
995
|
+
/** busy で defer した +新規 の保留 TTL。ready が来ないまま席を立った等で破棄する。 */
|
|
996
|
+
const PENDING_FRESH_TTL_MS = 600_000
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* 【構造版 root 2026-06-22】bind 時の即時 busy プローブ (プロセス実体)。
|
|
1000
|
+
* 「ターン進行中の claude を kill しない」判定の唯一の根拠にする。capture スクレイプや armed の
|
|
1001
|
+
* 140s cap の脆さに依存しない:
|
|
1002
|
+
* - isArmed: prompt_submit→ready の間 true (生成中 + Stop フック中を含む)。一次シグナル。
|
|
1003
|
+
* - 補強: pane_pid 子孫数 > 学習 baseline を ps スナップショットで直接確認 (armed cap / 未観測の
|
|
1004
|
+
* 隙間でも取りこぼさない)。baseline 未学習 (= ターン未観測の純 idle) は false (idle の +新規を妨げない)。
|
|
1005
|
+
* 判定不能時は安全側 false (= kill 許可) で idle の +新規 を妨げない。armed が一次防御なので、
|
|
1006
|
+
* 真にターン中なら isArmed=true でここに来る前に true を返す。
|
|
1007
|
+
*/
|
|
1008
|
+
async function isPaneBusyNow(readinessTracker, name) {
|
|
1009
|
+
if (!name || !readinessTracker) return false
|
|
1010
|
+
if (readinessTracker.isArmed(name) === true) return true
|
|
1011
|
+
try {
|
|
1012
|
+
const panePid = await getPanePid(name)
|
|
1013
|
+
if (!panePid) return false
|
|
1014
|
+
const procMap = await snapshotProcs()
|
|
1015
|
+
if (procMap.size === 0) return false
|
|
1016
|
+
const baseline = readinessTracker.getBaseline(name)
|
|
1017
|
+
if (baseline === null) return false
|
|
1018
|
+
return busyChildCount(panePid, procMap) > baseline
|
|
1019
|
+
} catch {
|
|
1020
|
+
return false
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
953
1024
|
/**
|
|
954
1025
|
* arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
|
|
955
1026
|
* 高速 poll し、子プロセスが baseline へ復帰したエッジで event ファイルに 'ready' を書く。
|
|
956
1027
|
* 既存の startSessionEventWatcher がそれを `session.event` 'ready' として push し、frontend は
|
|
957
1028
|
* 「真に入力可能」になった瞬間として消灯 + キューフラッシュする (Stop フック中の誤フラッシュ
|
|
958
1029
|
* 根治)。tracker.getBusy() は state loop が proc_busy レベルとして相乗せする。
|
|
1030
|
+
* onReady(name): ready エッジで呼ぶコールバック (busy で defer した +新規 の実行に使う)。
|
|
959
1031
|
*
|
|
960
1032
|
* 負荷: アイドル (armed/busy なし) のときは ps を spawn しない。claude pid は TTL キャッシュ。
|
|
961
1033
|
*/
|
|
962
|
-
function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
|
|
1034
|
+
function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
|
|
963
1035
|
let stopped = false
|
|
964
1036
|
const PANE_PID_TTL_MS = 30_000
|
|
965
1037
|
const panePidByName = new Map() // name → {pid, at}
|
|
@@ -1015,6 +1087,12 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
|
|
|
1015
1087
|
const { ready } = tracker.observe(name, count)
|
|
1016
1088
|
if (ready) {
|
|
1017
1089
|
await writeSessionEventFile(name, "ready", Date.now())
|
|
1090
|
+
// busy で defer した +新規 をターン終了 (ready) のここで 1 回だけ実行する。
|
|
1091
|
+
try {
|
|
1092
|
+
await onReady?.(name)
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
logger?.warn({ err: err?.message }, "readiness onReady callback failed")
|
|
1095
|
+
}
|
|
1018
1096
|
}
|
|
1019
1097
|
}
|
|
1020
1098
|
} catch (err) {
|
|
@@ -1433,6 +1511,13 @@ export function handleUntrackedPtyData(msg, ctx) {
|
|
|
1433
1511
|
}
|
|
1434
1512
|
}
|
|
1435
1513
|
|
|
1514
|
+
/**
|
|
1515
|
+
* 離席中キュー投入 (claude.tui.queue.flush) の冪等化用に、処理済み flush_id を記憶する。
|
|
1516
|
+
* WS 再送で同じ flush が二重に届いても二重 paste しないため。上限を超えたら全消去 (best-effort)。
|
|
1517
|
+
*/
|
|
1518
|
+
const _seenFlushIds = new Set()
|
|
1519
|
+
const _SEEN_FLUSH_CAP = 2000
|
|
1520
|
+
|
|
1436
1521
|
async function dispatch(msg, ctx) {
|
|
1437
1522
|
const t = msg?.type || ""
|
|
1438
1523
|
try {
|
|
@@ -1974,6 +2059,58 @@ async function dispatch(msg, ctx) {
|
|
|
1974
2059
|
})()
|
|
1975
2060
|
return
|
|
1976
2061
|
}
|
|
2062
|
+
case "claude.tui.queue.flush": {
|
|
2063
|
+
// 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
|
|
2064
|
+
// ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの
|
|
2065
|
+
// 送信待ちキュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、
|
|
2066
|
+
// そのセッションのターミナルを誰も開いていなくても届く。結果を flush.ack で返し、
|
|
2067
|
+
// ブラウザは ack を受けてはじめて localStorage から 1 件 dequeue する (冪等)。
|
|
2068
|
+
const sessionName =
|
|
2069
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
2070
|
+
const text = typeof msg.text === "string" ? msg.text : ""
|
|
2071
|
+
const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
|
|
2072
|
+
const ackFail = (reason) => {
|
|
2073
|
+
if (flushId && sessionName) {
|
|
2074
|
+
ctx.client.send({
|
|
2075
|
+
type: "claude.tui.queue.flush.ack",
|
|
2076
|
+
session_name: sessionName,
|
|
2077
|
+
flush_id: flushId,
|
|
2078
|
+
ok: false,
|
|
2079
|
+
error: reason,
|
|
2080
|
+
})
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
if (!sessionName || !flushId || !text) {
|
|
2084
|
+
ackFail("missing session_name / flush_id / text")
|
|
2085
|
+
return
|
|
2086
|
+
}
|
|
2087
|
+
// 冪等化: 同一 flush_id を既に処理済みなら再 paste せず ok を返す (ブラウザは dequeue 済み
|
|
2088
|
+
// を再確認するだけ)。WS 再送・ack 取りこぼしによる二重投入を防ぐ。
|
|
2089
|
+
if (_seenFlushIds.has(flushId)) {
|
|
2090
|
+
ctx.client.send({
|
|
2091
|
+
type: "claude.tui.queue.flush.ack",
|
|
2092
|
+
session_name: sessionName,
|
|
2093
|
+
flush_id: flushId,
|
|
2094
|
+
ok: true,
|
|
2095
|
+
})
|
|
2096
|
+
return
|
|
2097
|
+
}
|
|
2098
|
+
;(async () => {
|
|
2099
|
+
const result = await pasteToSessionByName(sessionName, text, { logger })
|
|
2100
|
+
if (result.ok) {
|
|
2101
|
+
if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
|
|
2102
|
+
_seenFlushIds.add(flushId)
|
|
2103
|
+
}
|
|
2104
|
+
ctx.client.send({
|
|
2105
|
+
type: "claude.tui.queue.flush.ack",
|
|
2106
|
+
session_name: sessionName,
|
|
2107
|
+
flush_id: flushId,
|
|
2108
|
+
ok: !!result.ok,
|
|
2109
|
+
error: result.ok ? undefined : result.error,
|
|
2110
|
+
})
|
|
2111
|
+
})()
|
|
2112
|
+
return
|
|
2113
|
+
}
|
|
1977
2114
|
case "claude.tui.rehydratePermissions": {
|
|
1978
2115
|
// セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
|
|
1979
2116
|
// state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
|
|
@@ -2085,8 +2222,22 @@ async function dispatch(msg, ctx) {
|
|
|
2085
2222
|
// (症状D 再発 D2: armed 140s cap / capture gap で generating=false に倒れ、--resume bind
|
|
2086
2223
|
// でも実行中 claude を kill していた)を、capture/armed に依存しない pane_current_command で防ぐ。
|
|
2087
2224
|
const paneRunningClaude = await isPaneRunningClaude(sessionName, { logger })
|
|
2225
|
+
// 【構造版 root 2026-06-22】プロセス実体の busy (ターン進行中) を bind 時に確定する。
|
|
2226
|
+
// 「動いている claude を kill しない」の唯一の判定基準。frontend が fresh:true を誤送しても
|
|
2227
|
+
// 生成中 claude を殺さない (frontend 単独依存からの脱却)。
|
|
2228
|
+
const busy = await isPaneBusyNow(ctx.readinessTracker, sessionName)
|
|
2229
|
+
// 非 fresh (resume/auto) の bind = ユーザーが既存会話を選んだ → 保留中の +新規 defer は破棄。
|
|
2230
|
+
if (!fresh) ctx.pendingFreshRespawn.delete(sessionName)
|
|
2088
2231
|
let rebind = { ok: false, skipped: false }
|
|
2089
2232
|
const bindKey = fresh ? `fresh:${request_id || ""}` : targetId
|
|
2233
|
+
const recordDeferredFresh = () =>
|
|
2234
|
+
ctx.pendingFreshRespawn.set(sessionName, {
|
|
2235
|
+
cwd,
|
|
2236
|
+
request_id,
|
|
2237
|
+
at: Date.now(),
|
|
2238
|
+
model: ctx.config?.claude_model || "",
|
|
2239
|
+
permissionMode: ctx.config?.claude_permission_mode || "",
|
|
2240
|
+
})
|
|
2090
2241
|
if (bindKey && sessionName && (fresh || targetId)) {
|
|
2091
2242
|
if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
|
|
2092
2243
|
rebind = { ok: true, skipped: true }
|
|
@@ -2097,16 +2248,37 @@ async function dispatch(msg, ctx) {
|
|
|
2097
2248
|
targetId,
|
|
2098
2249
|
newestId,
|
|
2099
2250
|
paneRunningClaude,
|
|
2251
|
+
busy,
|
|
2100
2252
|
})
|
|
2101
2253
|
) {
|
|
2102
|
-
//
|
|
2103
|
-
|
|
2104
|
-
|
|
2254
|
+
// busy (ターン進行中) または最新セッションへの再 bind → respawn を抑止し生成中 claude を温存。
|
|
2255
|
+
rebind = { ok: true, skipped: true }
|
|
2256
|
+
if (fresh && busy) {
|
|
2257
|
+
// ユーザーの +新規 を ready (ターン終了) まで defer する (in-flight ターンを殺さない)。
|
|
2258
|
+
recordDeferredFresh()
|
|
2259
|
+
logger?.info(
|
|
2260
|
+
{ session: sessionName },
|
|
2261
|
+
"tui rebind: deferred +new (pane busy) until turn ends",
|
|
2262
|
+
)
|
|
2263
|
+
} else {
|
|
2264
|
+
// 非 fresh の冪等記録 (動いているセッション本人への再 bind)。
|
|
2265
|
+
ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
2266
|
+
logger?.info(
|
|
2267
|
+
{ session: sessionName, session_id: targetId },
|
|
2268
|
+
"tui rebind: skipped respawn (turn in progress on the running session)",
|
|
2269
|
+
)
|
|
2270
|
+
}
|
|
2271
|
+
} else if (
|
|
2272
|
+
fresh &&
|
|
2273
|
+
Date.now() - (ctx.lastFreshRespawnAt.get(sessionName) || 0) <
|
|
2274
|
+
FRESH_RESPAWN_THROTTLE_MS
|
|
2275
|
+
) {
|
|
2276
|
+
// idle ペインへの fresh remount storm (再マウント毎に新 request_id) を 1 回へ collapse。
|
|
2277
|
+
// 直近 fresh respawn から閾値内の fresh は再送とみなし skip (new セッション乱造を防ぐ)。
|
|
2105
2278
|
rebind = { ok: true, skipped: true }
|
|
2106
|
-
ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
2107
2279
|
logger?.info(
|
|
2108
|
-
{ session: sessionName
|
|
2109
|
-
"tui rebind:
|
|
2280
|
+
{ session: sessionName },
|
|
2281
|
+
"tui rebind: throttled fresh respawn (idempotent remount within window)",
|
|
2110
2282
|
)
|
|
2111
2283
|
} else {
|
|
2112
2284
|
rebind = await rebindClaudeSession(sessionName, targetId, {
|
|
@@ -2114,9 +2286,17 @@ async function dispatch(msg, ctx) {
|
|
|
2114
2286
|
model: ctx.config?.claude_model || "",
|
|
2115
2287
|
permissionMode: ctx.config?.claude_permission_mode || "",
|
|
2116
2288
|
fresh,
|
|
2289
|
+
busy,
|
|
2117
2290
|
logger,
|
|
2118
2291
|
})
|
|
2119
|
-
if (rebind.ok)
|
|
2292
|
+
if (rebind.ok) {
|
|
2293
|
+
ctx.tuiReboundSessions.set(sessionName, bindKey)
|
|
2294
|
+
if (fresh) ctx.lastFreshRespawnAt.set(sessionName, Date.now())
|
|
2295
|
+
} else if (rebind.skipped && rebind.reason === "busy" && fresh) {
|
|
2296
|
+
// チョークポイントが busy で弾いた (probe が false でも実行直前にターン開始した競合)。
|
|
2297
|
+
// defer に載せて +新規 を取りこぼさない。
|
|
2298
|
+
recordDeferredFresh()
|
|
2299
|
+
}
|
|
2120
2300
|
}
|
|
2121
2301
|
}
|
|
2122
2302
|
reply({
|
package/src/proc-introspect.mjs
CHANGED
|
@@ -324,6 +324,12 @@ export class ReadinessTracker {
|
|
|
324
324
|
return this.byName.get(name)?.busy ?? false
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
/** 学習済み idle baseline (子孫プロセス数の床)。null=未学習。bind ハンドラの即時 busy
|
|
328
|
+
* プローブ (count > baseline で「ターン進行中」を実プロセスから判定) に使う。 */
|
|
329
|
+
getBaseline(name) {
|
|
330
|
+
return this.byName.get(name)?.baseline ?? null
|
|
331
|
+
}
|
|
332
|
+
|
|
327
333
|
/** ターン進行中か (prompt_submit で arm 後、ready/cap で disarm するまで true)。
|
|
328
334
|
* 生成中・Stop フック中の両方を含む。state loop が権威的な stall 判定に使う。 */
|
|
329
335
|
isArmed(name) {
|
package/src/tmux.mjs
CHANGED
|
@@ -796,6 +796,17 @@ export function isSafeSessionId(sessionId) {
|
|
|
796
796
|
return typeof sessionId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(sessionId)
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
+
/** tmux セッション名として安全な形だけ許可する。`-t <name>` の引数に流すため、先頭ハイフン
|
|
800
|
+
* (フラグ誤認) や予期せぬ文字を弾く。agent が作るセッション名は英数・`_`・`-`・`.` のみ。 */
|
|
801
|
+
export function isSafeSessionName(name) {
|
|
802
|
+
return typeof name === "string" && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(name)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** bracketed paste マーカーと貼り付け確定の待ち時間 (pasteToSessionByName 用)。 */
|
|
806
|
+
const PASTE_START = "\x1b[200~"
|
|
807
|
+
const PASTE_END = "\x1b[201~"
|
|
808
|
+
const PASTE_CONFIRM_DELAY_MS = 120
|
|
809
|
+
|
|
799
810
|
/** agent 設定 (model / permissionMode) を claude のフラグ文字列に変換する ("" or "--model X ...")。 */
|
|
800
811
|
export function composeClaudeFlags(opts = {}) {
|
|
801
812
|
const model = (opts.model || "").trim()
|
|
@@ -953,6 +964,55 @@ export async function resumeWithMessage(name, text, opts = {}) {
|
|
|
953
964
|
}
|
|
954
965
|
}
|
|
955
966
|
|
|
967
|
+
/**
|
|
968
|
+
* 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
|
|
969
|
+
* マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
|
|
970
|
+
*
|
|
971
|
+
* resumeWithMessage は改行を空白へ畳む単一行用 (誤確定防止) でチャット本文の複数行を壊すため、
|
|
972
|
+
* こちらは **bracketed paste** (ESC[200~ … ESC[201~) で本文を「貼り付け」として送り、改行を
|
|
973
|
+
* 保ったまま入力欄へ入れてから、確定の Enter (CR) を別途送る。これは frontend の通常送信
|
|
974
|
+
* (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。PTY 非依存
|
|
975
|
+
* (tmux send-keys) なので、当該セッションのターミナルを誰も開いていなくても届く。
|
|
976
|
+
*
|
|
977
|
+
* @param {string} name tmux セッション名
|
|
978
|
+
* @param {string} text 投入する本文 (複数行可)
|
|
979
|
+
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
980
|
+
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
981
|
+
*/
|
|
982
|
+
export async function pasteToSessionByName(name, text, opts = {}) {
|
|
983
|
+
const bin = tmuxBin(opts)
|
|
984
|
+
const body = String(text ?? "")
|
|
985
|
+
if (!body) return { ok: false, error: "empty paste text" }
|
|
986
|
+
if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
|
|
987
|
+
try {
|
|
988
|
+
// copy-mode 等に入っているとキーが奪われるので先に抜ける (cyclePermission と同じ防御)。
|
|
989
|
+
try {
|
|
990
|
+
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
991
|
+
} catch {
|
|
992
|
+
/* copy-mode でなければ "not in a mode" エラー = 無視 */
|
|
993
|
+
}
|
|
994
|
+
// bracketed paste の開始/終了マーカーと本文を literal (-l) で送る。改行を含む本文も
|
|
995
|
+
// 「貼り付け」として扱われ、Claude TUI は改行で確定しない (CR を別送するまで未確定)。
|
|
996
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_START])
|
|
997
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", body])
|
|
998
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_END])
|
|
999
|
+
// 貼り付けが入力欄へ反映される猶予を置いてから確定 (CR を本文より先着させない)。
|
|
1000
|
+
await _delay(PASTE_CONFIRM_DELAY_MS)
|
|
1001
|
+
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1002
|
+
opts.logger?.info(
|
|
1003
|
+
{ session: name, len: body.length },
|
|
1004
|
+
"tui queue flush: pasted queued message (headless)",
|
|
1005
|
+
)
|
|
1006
|
+
return { ok: true }
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
opts.logger?.warn(
|
|
1009
|
+
{ session: name, err: err?.message },
|
|
1010
|
+
"pasteToSessionByName failed",
|
|
1011
|
+
)
|
|
1012
|
+
return { ok: false, error: err?.message || String(err) }
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
956
1016
|
/**
|
|
957
1017
|
* 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
|
|
958
1018
|
*
|
|
@@ -1271,20 +1331,40 @@ export function shouldSkipRebindRespawn({
|
|
|
1271
1331
|
targetId,
|
|
1272
1332
|
newestId,
|
|
1273
1333
|
paneRunningClaude,
|
|
1334
|
+
busy = false,
|
|
1274
1335
|
} = {}) {
|
|
1336
|
+
// 【最優先・無条件の安全弁 — 構造版 根治 2026-06-22】ペインが現にターン進行中 (busy) なら、
|
|
1337
|
+
// fresh / resume / switch のいずれであっても respawn-pane -k で実行中 claude を kill しない。
|
|
1338
|
+
// busy は呼び出し側がプロセス実体 (ReadinessTracker.isArmed = prompt_submit→ready の間、
|
|
1339
|
+
// または pane_pid 子孫数 > 学習 baseline) で算出する堅い信号で、capture スクレイプや
|
|
1340
|
+
// armed 140s cap の脆さに依存しない。これにより「動いている claude は誰の要求でも殺さない」を
|
|
1341
|
+
// 唯一の破壊点 (rebindClaudeSession) の手前で構造的に保証する。
|
|
1342
|
+
// 旧バグ: `if (fresh) return false` が fresh:true を無条件に respawn(=kill) へ通し、防御を完全に
|
|
1343
|
+
// frontend (fresh を送らない前提) 単独依存にしていた。frontend の 1 経路でも fresh が漏れると
|
|
1344
|
+
// 生成中 claude が死ぬ事故 (症状D) が再発した。busy ゲートで前提依存を断つ。
|
|
1345
|
+
// fresh の +新規 要求が busy で抑止された場合、呼び出し側は busy 解消 (ready) まで defer する。
|
|
1346
|
+
if (busy) return true
|
|
1275
1347
|
if (fresh) return false
|
|
1276
|
-
// 実行中 claude を kill
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
// で false に倒れても、実プロセス由来の paneRunningClaude が守る。targetId 未指定(= newest 解決前)
|
|
1280
|
-
// も「最新への再 bind」とみなす。明示的な別会話への切替(targetId !== newestId)と +新規(fresh)は
|
|
1281
|
-
// 従来どおり respawn する。
|
|
1348
|
+
// 実行中 claude を kill しない補助ガード: ペインが現に claude を実行中で、最新セッション
|
|
1349
|
+
// (= 今動いている会話)への再 bind(remount/reconnect)なら respawn は破壊的なだけで不要。
|
|
1350
|
+
// busy が false に倒れても (cap/未観測)、pane_current_command 由来の paneRunningClaude が守る。
|
|
1282
1351
|
if (paneRunningClaude && (!targetId || targetId === newestId)) return true
|
|
1283
1352
|
if (!generating) return false
|
|
1284
1353
|
return !!targetId && targetId === newestId
|
|
1285
1354
|
}
|
|
1286
1355
|
|
|
1287
1356
|
export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
1357
|
+
// 【破壊点チョークポイント — 最終安全弁 2026-06-22】respawn-pane -k を実行する唯一の関数。
|
|
1358
|
+
// busy=true (ターン進行中) なら shouldSkipRebindRespawn を経由せず直接呼ばれても respawn を
|
|
1359
|
+
// 絶対に発行しない。これで「動いている claude を kill しない」不変条件を実行地点 1 箇所で
|
|
1360
|
+
// 保証する (呼び出し側の判定漏れに対する多層防御)。呼び出し側が ready まで defer する。
|
|
1361
|
+
if (opts.busy === true) {
|
|
1362
|
+
opts.logger?.info(
|
|
1363
|
+
{ session: name, fresh: !!opts.fresh },
|
|
1364
|
+
"tui rebind: skipped respawn at chokepoint (pane busy, deferred)",
|
|
1365
|
+
)
|
|
1366
|
+
return { ok: false, skipped: true, reason: "busy" }
|
|
1367
|
+
}
|
|
1288
1368
|
let cmd
|
|
1289
1369
|
try {
|
|
1290
1370
|
// fresh=新規起動 (--resume 無し)。それ以外は session_id 検証込みの resume。
|