@cocorograph/hub-agent 0.7.105 → 0.8.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 +0 -113
- package/src/claude-stream-bridge.mjs +183 -10
- package/src/codex-profiles.mjs +43 -0
- package/src/codex-stream-bridge.mjs +30 -42
- package/src/main.mjs +216 -1120
- package/src/profiles.mjs +42 -0
- package/src/tmux.mjs +6 -763
- package/src/usage.mjs +184 -3
- package/src/extract-paragraph.mjs +0 -149
- package/src/jsonl-live-watchers.mjs +0 -283
- package/src/tui-permission-bridge.mjs +0 -280
- package/src/tui-viewer-registry.mjs +0 -201
package/src/tmux.mjs
CHANGED
|
@@ -970,47 +970,6 @@ const PASTE_START = "\x1b[200~"
|
|
|
970
970
|
const PASTE_END = "\x1b[201~"
|
|
971
971
|
const PASTE_CONFIRM_DELAY_MS = 120
|
|
972
972
|
|
|
973
|
-
/** agent 設定 (model / permissionMode) を claude のフラグ文字列に変換する ("" or "--model X ...")。 */
|
|
974
|
-
export function composeClaudeFlags(opts = {}) {
|
|
975
|
-
const model = (opts.model || "").trim()
|
|
976
|
-
const mode = (opts.permissionMode || "").trim()
|
|
977
|
-
const flags = []
|
|
978
|
-
if (model) flags.push(`--model ${model}`)
|
|
979
|
-
if (mode) flags.push(`--permission-mode ${mode}`)
|
|
980
|
-
return flags.join(" ")
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* TUI 載せ替え用の `claude --resume <id> [flags]` コマンド文字列を組み立てる (純粋関数)。
|
|
985
|
-
* session_id を明示 resume することで、表示(read)と書込(write)の jsonl を一致させる
|
|
986
|
-
* (`--continue` は解決後 id 不明で read/write が乖離しうるため不可)。
|
|
987
|
-
*
|
|
988
|
-
* @param {string} sessionId
|
|
989
|
-
* @param {{model?:string,permissionMode?:string}} [opts]
|
|
990
|
-
* @returns {string}
|
|
991
|
-
* @throws session_id が安全形でなければ throw (コマンド注入防止)
|
|
992
|
-
*/
|
|
993
|
-
export function buildResumeCmd(sessionId, opts = {}) {
|
|
994
|
-
if (!isSafeSessionId(sessionId)) {
|
|
995
|
-
throw new Error(`unsafe session_id for resume: ${String(sessionId)}`)
|
|
996
|
-
}
|
|
997
|
-
const flags = composeClaudeFlags(opts)
|
|
998
|
-
return `claude --resume ${sessionId}${flags ? " " + flags : ""}`.trim()
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* まっさらな新規セッション起動用の `claude [flags]` コマンド文字列を組み立てる (純粋関数)。
|
|
1003
|
-
* --resume を付けないので claude は新しい session_id (新 jsonl) で起動する。Cockpit の
|
|
1004
|
-
* セッション選択プルダウンの「+新規」で、cwd 最新へ resume せず仕切り直したいときに使う。
|
|
1005
|
-
*
|
|
1006
|
-
* @param {{model?:string,permissionMode?:string}} [opts]
|
|
1007
|
-
* @returns {string}
|
|
1008
|
-
*/
|
|
1009
|
-
export function buildFreshCmd(opts = {}) {
|
|
1010
|
-
const flags = composeClaudeFlags(opts)
|
|
1011
|
-
return `claude${flags ? " " + flags : ""}`.trim()
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
973
|
// ---------------------------------------------------------------------------
|
|
1015
974
|
// Codex CLI 起動コマンドビルダ (claude 版と同形の純粋関数)
|
|
1016
975
|
//
|
|
@@ -1211,327 +1170,6 @@ async function _confirmSwitchDialog(bin, name, opts) {
|
|
|
1211
1170
|
return true
|
|
1212
1171
|
}
|
|
1213
1172
|
|
|
1214
|
-
/**
|
|
1215
|
-
* 遅延回答 resume: 承認/質問カードへの回答がフックの生存中に間に合わなかったとき、
|
|
1216
|
-
* 回答を「新しいユーザーメッセージ」として同一 tmux セッションの対話 claude へ届ける。
|
|
1217
|
-
*
|
|
1218
|
-
* TUI バックエンドの claude は tmux 上で永続し会話文脈を保持するため、回答を新メッセージ
|
|
1219
|
-
* として送れば「離席→タイムアウト」後でも文脈を引き継いで続行できる (ユーザー指示の設計)。
|
|
1220
|
-
*
|
|
1221
|
-
* フックがタイムアウトすると claude は native の質問/承認 UI を pane に出してターンを
|
|
1222
|
-
* 止めている。先に Escape を送ってそれを畳み (素の入力欄では無害)、少し待ってから回答
|
|
1223
|
-
* テキストを送って Enter で確定する。テキストは send-keys -l で「文字列リテラル」として
|
|
1224
|
-
* 送り、改行は空白へ畳んで単一行にする (誤確定防止)。ベストエフォート。
|
|
1225
|
-
*
|
|
1226
|
-
* @param {string} name tmux セッション名
|
|
1227
|
-
* @param {string} text 送る回答メッセージ (人間可読)
|
|
1228
|
-
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
1229
|
-
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
1230
|
-
*/
|
|
1231
|
-
export async function resumeWithMessage(name, text, opts = {}) {
|
|
1232
|
-
const bin = tmuxBin(opts)
|
|
1233
|
-
const line = String(text || "")
|
|
1234
|
-
.replace(/[\r\n]+/g, " ")
|
|
1235
|
-
.trim()
|
|
1236
|
-
if (!line) return { ok: false, error: "empty resume text" }
|
|
1237
|
-
try {
|
|
1238
|
-
// 1) native の質問/承認 UI を畳む (素の入力欄なら入力クリアで無害)。
|
|
1239
|
-
await execFileP(bin, ["send-keys", "-t", name, "Escape"])
|
|
1240
|
-
await _delay(200)
|
|
1241
|
-
// 2) 回答テキストをリテラルで送る (-l でキー名解釈を避ける)。
|
|
1242
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", line])
|
|
1243
|
-
await _delay(120)
|
|
1244
|
-
// 3) Enter で確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
|
|
1245
|
-
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1246
|
-
opts.logger?.info(
|
|
1247
|
-
{ session: name, len: line.length },
|
|
1248
|
-
"tui resume: delivered late answer as new message",
|
|
1249
|
-
)
|
|
1250
|
-
return { ok: true }
|
|
1251
|
-
} catch (err) {
|
|
1252
|
-
opts.logger?.warn(
|
|
1253
|
-
{ session: name, err: err?.message },
|
|
1254
|
-
"resumeWithMessage failed",
|
|
1255
|
-
)
|
|
1256
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// ── AskUserQuestion / ExitPlanMode ネイティブメニューへの send-keys 回答 (症状1a 根治, 1-B) ──
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* メニュー描画の anchor。通常の選択画面 footer ("Enter to select") に加え、最終確定画面
|
|
1264
|
-
* ("❯ 1. Submit answers"。footer 文言が異なる可能性があるため "Submit answers" でも拾う) を
|
|
1265
|
-
* 取りこぼさない。anchor がマッチしても option/submit 行が無ければ parseAskMenu は null を返す。
|
|
1266
|
-
*/
|
|
1267
|
-
const ASK_MENU_FOOTER_RE = /Enter to (?:select|confirm)|Submit answers/i
|
|
1268
|
-
/** 選択肢行: "❯? N. [✔]? label"。先頭 ❯ = カーソル、[ ]/[✔] = multiSelect チェックボックス。 */
|
|
1269
|
-
const ASK_OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(\[\s*([ xX✔✓])\s*\]\s+)?(.*\S)\s*$/
|
|
1270
|
-
/** 番号なしの "Submit" 行 (multiSelect の確定行)。 */
|
|
1271
|
-
const ASK_SUBMIT_ROW_RE = /^\s*(❯)?\s*Submit\s*$/
|
|
1272
|
-
const ASK_TYPE_SOMETHING_RE = /Type something/i
|
|
1273
|
-
|
|
1274
|
-
/**
|
|
1275
|
-
* capturePane テキストから AskUserQuestion ネイティブメニューを構造化する (純関数, テスト用に export)。
|
|
1276
|
-
* メニュー未描画なら null。複数質問では「現在表示中の質問」の選択肢のみが見える。
|
|
1277
|
-
* @param {string} paneText capturePane の出力 (ANSI 除去済み)
|
|
1278
|
-
* @returns {{options:Array<{num:number,cursor:boolean,checked:boolean|null,label:string,isCustom:boolean,isSubmitAnswers:boolean}>,submitRow:boolean,submitRowCursor:boolean,submitAnswers:boolean,multiSelect:boolean}|null}
|
|
1279
|
-
*/
|
|
1280
|
-
export function parseAskMenu(paneText) {
|
|
1281
|
-
if (typeof paneText !== "string" || !ASK_MENU_FOOTER_RE.test(paneText)) {
|
|
1282
|
-
return null
|
|
1283
|
-
}
|
|
1284
|
-
const lines = paneText.replace(/\r/g, "").split("\n")
|
|
1285
|
-
const options = []
|
|
1286
|
-
let submitRow = false
|
|
1287
|
-
let submitRowCursor = false
|
|
1288
|
-
let multiSelect = false
|
|
1289
|
-
for (const line of lines) {
|
|
1290
|
-
const m = ASK_OPTION_RE.exec(line)
|
|
1291
|
-
if (m) {
|
|
1292
|
-
const hasCheckbox = m[3] !== undefined
|
|
1293
|
-
if (hasCheckbox) multiSelect = true
|
|
1294
|
-
const label = m[5].trim()
|
|
1295
|
-
options.push({
|
|
1296
|
-
num: Number(m[2]),
|
|
1297
|
-
cursor: !!m[1],
|
|
1298
|
-
checked: hasCheckbox ? /[xX✔✓]/.test(m[4]) : null,
|
|
1299
|
-
label,
|
|
1300
|
-
isCustom: ASK_TYPE_SOMETHING_RE.test(label),
|
|
1301
|
-
isSubmitAnswers: /^Submit answers$/i.test(label),
|
|
1302
|
-
})
|
|
1303
|
-
continue
|
|
1304
|
-
}
|
|
1305
|
-
const sm = ASK_SUBMIT_ROW_RE.exec(line)
|
|
1306
|
-
if (sm) {
|
|
1307
|
-
submitRow = true
|
|
1308
|
-
if (sm[1]) submitRowCursor = true
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
if (options.length === 0 && !submitRow) return null
|
|
1312
|
-
return {
|
|
1313
|
-
options,
|
|
1314
|
-
submitRow,
|
|
1315
|
-
submitRowCursor,
|
|
1316
|
-
submitAnswers: options.some((o) => o.isSubmitAnswers),
|
|
1317
|
-
multiSelect,
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
/** ラベル一致: 完全 → 前方 → 含む の順 (claude のラベル truncate / 表記揺れ保険)。 */
|
|
1322
|
-
function _findAskOption(options, label) {
|
|
1323
|
-
const want = String(label || "").trim()
|
|
1324
|
-
if (!want) return null
|
|
1325
|
-
const pool = options.filter((o) => !o.isSubmitAnswers)
|
|
1326
|
-
return (
|
|
1327
|
-
pool.find((o) => o.label === want) ||
|
|
1328
|
-
pool.find((o) => o.label.startsWith(want) || want.startsWith(o.label)) ||
|
|
1329
|
-
pool.find((o) => o.label.includes(want) || want.includes(o.label)) ||
|
|
1330
|
-
null
|
|
1331
|
-
)
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
/** 現在の質問シグネチャ (選択肢ラベル集合)。質問遷移 / 消失の検知に使う。 */
|
|
1335
|
-
function _askMenuSig(menu) {
|
|
1336
|
-
if (!menu) return null
|
|
1337
|
-
return (
|
|
1338
|
-
menu.options.map((o) => o.label).join("|") +
|
|
1339
|
-
(menu.submitAnswers ? "|SA" : "")
|
|
1340
|
-
)
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
/**
|
|
1344
|
-
* AskUserQuestion / ExitPlanMode のネイティブメニューへ実キーを送って回答する (1-B)。
|
|
1345
|
-
*
|
|
1346
|
-
* ブラウザのカード回答を、フックの updatedInput (AskUserQuestion では原理的に効かない) でなく
|
|
1347
|
-
* 同一ホストの tmux メニューへ send-keys で届ける。_confirmSwitchDialog と同じく capturePane で
|
|
1348
|
-
* 描画を検知してから送出し、状態変化を検証する (blind 送出はしない = 不変条件 B)。
|
|
1349
|
-
*
|
|
1350
|
-
* スパイク確定 (claude 2.1.195):
|
|
1351
|
-
* - single-select: 数字 N で即選択+確定 → 次質問へ自動遷移
|
|
1352
|
-
* - multiSelect: 数字 N でトグル → Submit 行へ ↓ → Enter
|
|
1353
|
-
* - 複数質問: 各質問回答で自動遷移 → 最終 "Submit answers" → Enter
|
|
1354
|
-
* - 自由入力: Type something の番号 → テキストモード → -l "<本文>" → Enter
|
|
1355
|
-
*
|
|
1356
|
-
* @param {string} name tmux セッション名
|
|
1357
|
-
* @param {Array<{question?:string, labels:string[]}>} answers 質問順の回答 (labels = 選んだ選択肢ラベル群)
|
|
1358
|
-
* @param {{logger?:object,tmuxBin?:string,appearMs?:number,pollMs?:number,stepMs?:number,dismissMs?:number}} [opts]
|
|
1359
|
-
* @returns {Promise<{ok:boolean, error?:string, answered:number}>}
|
|
1360
|
-
*/
|
|
1361
|
-
export async function answerAskUserQuestion(name, answers, opts = {}) {
|
|
1362
|
-
if (!isSafeSessionName(name)) {
|
|
1363
|
-
return { ok: false, error: "unsafe session name", answered: 0 }
|
|
1364
|
-
}
|
|
1365
|
-
if (!Array.isArray(answers) || answers.length === 0) {
|
|
1366
|
-
return { ok: false, error: "no answers", answered: 0 }
|
|
1367
|
-
}
|
|
1368
|
-
const bin = tmuxBin(opts)
|
|
1369
|
-
const appearMs = opts.appearMs ?? 4000
|
|
1370
|
-
const pollMs = opts.pollMs ?? 150
|
|
1371
|
-
const stepMs = opts.stepMs ?? 200
|
|
1372
|
-
const dismissMs = opts.dismissMs ?? 3000
|
|
1373
|
-
const cap = () => capturePane(name, { ...opts, noCache: true })
|
|
1374
|
-
|
|
1375
|
-
// copy-mode に入っているとキーが奪われるので先に抜ける (cyclePermission と同じ防御)。
|
|
1376
|
-
try {
|
|
1377
|
-
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1378
|
-
} catch {
|
|
1379
|
-
/* not in a mode = 無視 */
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// メニュー出現待ち (フック即 abstain 直後はレンダ遅延がある)。
|
|
1383
|
-
const APPEAR_DEADLINE = Date.now() + appearMs
|
|
1384
|
-
let menu = parseAskMenu(await cap())
|
|
1385
|
-
while (!menu && Date.now() < APPEAR_DEADLINE) {
|
|
1386
|
-
await _delay(pollMs)
|
|
1387
|
-
menu = parseAskMenu(await cap())
|
|
1388
|
-
}
|
|
1389
|
-
if (!menu) return { ok: false, error: "ask menu not visible", answered: 0 }
|
|
1390
|
-
|
|
1391
|
-
// 現在の質問が「変わる(=遷移) / 消える」まで待つ。timeout でも先へ進む (best-effort)。
|
|
1392
|
-
const waitChanged = async (prevSig) => {
|
|
1393
|
-
const dl = Date.now() + dismissMs
|
|
1394
|
-
while (Date.now() < dl) {
|
|
1395
|
-
const sig = _askMenuSig(parseAskMenu(await cap()))
|
|
1396
|
-
if (sig !== prevSig) return
|
|
1397
|
-
await _delay(pollMs)
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
let answered = 0
|
|
1402
|
-
const maxIters = answers.length * 3 + 8
|
|
1403
|
-
for (let guard = 0; guard < maxIters; guard++) {
|
|
1404
|
-
menu = parseAskMenu(await cap())
|
|
1405
|
-
if (!menu) return { ok: true, answered } // 全回答完了 (single-q single-select 等)
|
|
1406
|
-
|
|
1407
|
-
// "Submit answers" 確認画面 → 確定して次状態へ。
|
|
1408
|
-
const sa = menu.options.find((o) => o.isSubmitAnswers)
|
|
1409
|
-
if (sa) {
|
|
1410
|
-
const sig = _askMenuSig(menu)
|
|
1411
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(sa.num)])
|
|
1412
|
-
await waitChanged(sig)
|
|
1413
|
-
continue
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// 全回答済みなのにメニューが残る = 直前回答の反映ラグ (特に自由入力で Type something の
|
|
1417
|
-
// ラベルが入力文字へ変わり waitChanged が早期復帰した場合)。ここで「質問が余っている」と
|
|
1418
|
-
// 誤判定して ok:false を返すと frontend が回答済みカードを復元してしまう。完了待ち
|
|
1419
|
-
// (final block) へ抜けてメニュー消失を確認する。
|
|
1420
|
-
if (answered >= answers.length) break
|
|
1421
|
-
const entry = answers[answered]
|
|
1422
|
-
const labels = Array.isArray(entry.labels) ? entry.labels : []
|
|
1423
|
-
const sigBefore = _askMenuSig(menu)
|
|
1424
|
-
|
|
1425
|
-
if (menu.multiSelect) {
|
|
1426
|
-
// 各ラベルをトグル (既にチェック済みは触らない)。
|
|
1427
|
-
for (const label of labels) {
|
|
1428
|
-
const m2 = parseAskMenu(await cap())
|
|
1429
|
-
if (!m2) break
|
|
1430
|
-
const opt = _findAskOption(m2.options, label)
|
|
1431
|
-
if (opt && opt.checked !== true) {
|
|
1432
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
|
|
1433
|
-
await _delay(stepMs)
|
|
1434
|
-
} else if (!opt) {
|
|
1435
|
-
// 一致なし → 自由入力で補う (best-effort)。
|
|
1436
|
-
const custom = m2.options.find((o) => o.isCustom)
|
|
1437
|
-
if (custom) {
|
|
1438
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
|
|
1439
|
-
await _delay(stepMs)
|
|
1440
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(label)])
|
|
1441
|
-
await _delay(stepMs)
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
// Submit 行へカーソル移動して Enter (↓ を送りながら検証)。
|
|
1446
|
-
const navDl = Date.now() + dismissMs
|
|
1447
|
-
let submitted = false
|
|
1448
|
-
while (Date.now() < navDl) {
|
|
1449
|
-
const m3 = parseAskMenu(await cap())
|
|
1450
|
-
if (!m3 || m3.submitAnswers) {
|
|
1451
|
-
submitted = true
|
|
1452
|
-
break
|
|
1453
|
-
}
|
|
1454
|
-
if (m3.submitRowCursor) {
|
|
1455
|
-
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1456
|
-
await _delay(stepMs)
|
|
1457
|
-
submitted = true
|
|
1458
|
-
break
|
|
1459
|
-
}
|
|
1460
|
-
await execFileP(bin, ["send-keys", "-t", name, "Down"])
|
|
1461
|
-
await _delay(120)
|
|
1462
|
-
}
|
|
1463
|
-
if (!submitted) {
|
|
1464
|
-
return { ok: false, error: "multiSelect submit nav failed", answered }
|
|
1465
|
-
}
|
|
1466
|
-
} else {
|
|
1467
|
-
// single-select: ラベル一致の数字で即確定 (+自動遷移)。
|
|
1468
|
-
const label = labels[0]
|
|
1469
|
-
const opt = _findAskOption(menu.options, label)
|
|
1470
|
-
if (opt) {
|
|
1471
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
|
|
1472
|
-
} else {
|
|
1473
|
-
const custom = menu.options.find((o) => o.isCustom)
|
|
1474
|
-
if (!custom) return { ok: false, error: `option not found: ${label}`, answered }
|
|
1475
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
|
|
1476
|
-
await _delay(stepMs)
|
|
1477
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", String(label || "")])
|
|
1478
|
-
await _delay(stepMs)
|
|
1479
|
-
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
answered++
|
|
1483
|
-
await waitChanged(sigBefore) // 質問遷移 / 消失を確認してから次へ (二重回答防止)
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
// 完了待ち: メニューが消える (= 回答が claude に届いた) まで待つ。"Submit answers" が
|
|
1487
|
-
// 残っていれば確定する。消失を確認できたら ok:true。dismissMs 経っても残っている =
|
|
1488
|
-
// 回答が届いていない (真の失敗) ので ok:false を返す。ok の正確性が肝: ok:true なら
|
|
1489
|
-
// 回答後の tool_result を 1-A が検知して cancel でカードを畳む / ok:false なら frontend が
|
|
1490
|
-
// カードを復元して再回答できるようにする。両者が食い違うとカード状態が競合する。
|
|
1491
|
-
const finalDl = Date.now() + dismissMs
|
|
1492
|
-
while (Date.now() < finalDl) {
|
|
1493
|
-
const m = parseAskMenu(await cap())
|
|
1494
|
-
if (!m) return { ok: true, answered }
|
|
1495
|
-
const sa = m.options.find((o) => o.isSubmitAnswers)
|
|
1496
|
-
if (sa) await execFileP(bin, ["send-keys", "-t", name, "-l", String(sa.num)])
|
|
1497
|
-
await _delay(180)
|
|
1498
|
-
}
|
|
1499
|
-
opts.logger?.warn(
|
|
1500
|
-
{ session: name, answered },
|
|
1501
|
-
"answerAskUserQuestion: menu still visible after retries",
|
|
1502
|
-
)
|
|
1503
|
-
return { ok: false, error: "menu still visible after answering", answered }
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
/**
|
|
1507
|
-
* 対話 TUI claude へ中断 (Esc) を tmux send-keys で送る。PTY stream に依存しないため、
|
|
1508
|
-
* zombie WS / stream 欠落窓でも確実に届く (生 ESC を tracked pty.data で送る旧経路は stream
|
|
1509
|
-
* 欠落で無言ドロップしていた = 症状4の配達失敗半分)。copy-mode 等に入っているとキーが奪われる
|
|
1510
|
-
* ので先に -X cancel で抜ける。ESC は claude TUI の生成中断キー (素の入力欄では概ね無害)。
|
|
1511
|
-
* @param {string} name tmux セッション名
|
|
1512
|
-
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
1513
|
-
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
1514
|
-
*/
|
|
1515
|
-
export async function sendInterruptKey(name, opts = {}) {
|
|
1516
|
-
if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
|
|
1517
|
-
const bin = tmuxBin(opts)
|
|
1518
|
-
try {
|
|
1519
|
-
try {
|
|
1520
|
-
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1521
|
-
} catch {
|
|
1522
|
-
/* copy-mode でなければ "not in a mode" エラー = 無視 */
|
|
1523
|
-
}
|
|
1524
|
-
await execFileP(bin, ["send-keys", "-t", name, "Escape"])
|
|
1525
|
-
return { ok: true }
|
|
1526
|
-
} catch (err) {
|
|
1527
|
-
opts.logger?.warn(
|
|
1528
|
-
{ session: name, err: err?.message },
|
|
1529
|
-
"sendInterruptKey failed",
|
|
1530
|
-
)
|
|
1531
|
-
return { ok: false, error: err?.message || "send_failed" }
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
1173
|
/**
|
|
1536
1174
|
* 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
|
|
1537
1175
|
* マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
|
|
@@ -1581,328 +1219,13 @@ export async function pasteToSessionByName(name, text, opts = {}) {
|
|
|
1581
1219
|
}
|
|
1582
1220
|
}
|
|
1583
1221
|
|
|
1584
|
-
/**
|
|
1585
|
-
* 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
|
|
1586
|
-
*
|
|
1587
|
-
* 全端末で「実際に動いているターミナルの権限状態」を同期する仕組みの書込側
|
|
1588
|
-
* (読込側は state.mjs の detectPermissionModeFromText)。フロントからの権限バッジ
|
|
1589
|
-
* 押下は raw pty.data ではなく `claude.tui.cyclePermission` を送り、agent 側で
|
|
1590
|
-
* 本関数を実行 → ペイン再読込で実モードを確定 → 全ブラウザへ broadcast する。
|
|
1591
|
-
* これにより楽観値が端末間でズレず、jsonl 未記録 (未送信) の変更も即同期される。
|
|
1592
|
-
*
|
|
1593
|
-
* copy-mode 等に入っているとキーが奪われるので、入っていれば先に抜ける
|
|
1594
|
-
* (フロントの cancelTmuxMode 相当を agent 側でも担保)。ベストエフォート。
|
|
1595
|
-
*
|
|
1596
|
-
* @param {string} name tmux セッション名
|
|
1597
|
-
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
1598
|
-
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
1599
|
-
*/
|
|
1600
|
-
export async function cyclePermissionMode(name, opts = {}) {
|
|
1601
|
-
const bin = tmuxBin(opts)
|
|
1602
|
-
try {
|
|
1603
|
-
// copy-mode 等に入っていると BTab が奪われるので、入っている時だけ抜ける。
|
|
1604
|
-
try {
|
|
1605
|
-
const { stdout } = await execFileP(bin, [
|
|
1606
|
-
"display-message",
|
|
1607
|
-
"-p",
|
|
1608
|
-
"-t",
|
|
1609
|
-
`${name}:`,
|
|
1610
|
-
"-F",
|
|
1611
|
-
"#{pane_in_mode}",
|
|
1612
|
-
])
|
|
1613
|
-
if (stdout.trim() === "1") {
|
|
1614
|
-
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1615
|
-
}
|
|
1616
|
-
} catch {
|
|
1617
|
-
// pane_in_mode 取得失敗はベストエフォートで無視 (そのまま BTab を送る)。
|
|
1618
|
-
}
|
|
1619
|
-
// BTab = shift+tab。claude TUI が権限モードを 1 段循環する。
|
|
1620
|
-
await execFileP(bin, ["send-keys", "-t", name, "BTab"])
|
|
1621
|
-
return { ok: true }
|
|
1622
|
-
} catch (err) {
|
|
1623
|
-
opts.logger?.warn(
|
|
1624
|
-
{ session: name, err: err?.message },
|
|
1625
|
-
"cyclePermissionMode failed",
|
|
1626
|
-
)
|
|
1627
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
/**
|
|
1632
|
-
* 対話 claude TUI に `/model <id>` を送ってモデルを切り替える。
|
|
1633
|
-
*
|
|
1634
|
-
* cockpit のモデルバッジ選択 (claude.tui.setModel) の書込側。権限循環 (cyclePermissionMode)
|
|
1635
|
-
* と同じく、frontend は raw pty.data ではなく claude.tui.setModel を送り、agent 側で本関数を
|
|
1636
|
-
* 実行 → 全ブラウザへ claude.tui.model を broadcast して全端末を実モデルに揃える。
|
|
1637
|
-
*
|
|
1638
|
-
* `/model` はフルモデルID をそのまま受理する (起動時 `claude --model <id>` と同仕様)。引数
|
|
1639
|
-
* `default` は起動時の既定へ戻す。会話に出力済みの履歴があるとモデル切替時に確認プロンプトが
|
|
1640
|
-
* 出るため、Enter を送って本文確定 → 少し待って追い Enter で確認を畳む (素の入力欄では空 Enter
|
|
1641
|
-
* となり無害)。copy-mode に入っているとキーが奪われるので先に抜ける。ベストエフォート。
|
|
1642
|
-
*
|
|
1643
|
-
* @param {string} name tmux セッション名
|
|
1644
|
-
* @param {string} modelArg `/model` 引数 (フルモデルID または "default")
|
|
1645
|
-
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
1646
|
-
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
1647
|
-
*/
|
|
1648
|
-
export async function setTuiModel(name, modelArg, opts = {}) {
|
|
1649
|
-
const bin = tmuxBin(opts)
|
|
1650
|
-
const arg = String(modelArg || "default").replace(/[\r\n]+/g, " ").trim()
|
|
1651
|
-
if (!arg) return { ok: false, error: "empty model arg" }
|
|
1652
|
-
try {
|
|
1653
|
-
// copy-mode 等に入っているとキーが奪われるので、入っている時だけ抜ける。
|
|
1654
|
-
try {
|
|
1655
|
-
const { stdout } = await execFileP(bin, [
|
|
1656
|
-
"display-message",
|
|
1657
|
-
"-p",
|
|
1658
|
-
"-t",
|
|
1659
|
-
`${name}:`,
|
|
1660
|
-
"-F",
|
|
1661
|
-
"#{pane_in_mode}",
|
|
1662
|
-
])
|
|
1663
|
-
if (stdout.trim() === "1") {
|
|
1664
|
-
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1665
|
-
}
|
|
1666
|
-
} catch {
|
|
1667
|
-
// pane_in_mode 取得失敗はベストエフォートで無視。
|
|
1668
|
-
}
|
|
1669
|
-
// `/model <id>` をリテラルで送る (-l でキー名解釈・スラッシュ補完の暴発を避ける)。
|
|
1670
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", `/model ${arg}`])
|
|
1671
|
-
await _delay(120)
|
|
1672
|
-
// Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
|
|
1673
|
-
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1674
|
-
// prior output ありの場合に出る「Switch model?」確認ダイアログを、描画を検知してから
|
|
1675
|
-
// Enter で畳む (固定待ちの盲打ち Enter はレンダ遅延と競合して取りこぼすため)。
|
|
1676
|
-
await _confirmSwitchDialog(bin, name, opts)
|
|
1677
|
-
return { ok: true }
|
|
1678
|
-
} catch (err) {
|
|
1679
|
-
opts.logger?.warn(
|
|
1680
|
-
{ session: name, model: arg, err: err?.message },
|
|
1681
|
-
"setTuiModel failed",
|
|
1682
|
-
)
|
|
1683
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
/**
|
|
1688
|
-
* 対話 claude TUI に `/effort <level>` を送って reasoning effort を切り替える。
|
|
1689
|
-
*
|
|
1690
|
-
* cockpit の effort バッジ選択 (claude.tui.setEffort) の書込側。モデル切替 (setTuiModel) と
|
|
1691
|
-
* 同設計で、frontend は raw pty.data ではなく claude.tui.setEffort を送り、agent 側で本関数を
|
|
1692
|
-
* 実行 → 全ブラウザへ claude.tui.effort を broadcast して全端末を実 effort に揃える。
|
|
1693
|
-
*
|
|
1694
|
-
* `/effort` は引数 (low/medium/high/xhigh/max/auto) を受理する。effort 非対応レベルを
|
|
1695
|
-
* 与えても CLI がモデルの対応上限へ自動フォールバックする。`auto` はモデル既定へリセット。
|
|
1696
|
-
* claude v2.1.170 以降は会話に出力済みの履歴があると「Change effort level?」確認ダイアログ
|
|
1697
|
-
* を挟むようになった (モデル切替と同じ S1_ コンポーネント) ため、setTuiModel と同様に描画を
|
|
1698
|
-
* 検知してから Enter で畳む。copy-mode に入っているとキーが奪われるので先に抜ける。
|
|
1699
|
-
* ベストエフォート。
|
|
1700
|
-
*
|
|
1701
|
-
* @param {string} name tmux セッション名
|
|
1702
|
-
* @param {string} effortArg `/effort` 引数 (low/medium/high/xhigh/max または "auto")
|
|
1703
|
-
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
1704
|
-
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
1705
|
-
*/
|
|
1706
|
-
export async function setTuiEffort(name, effortArg, opts = {}) {
|
|
1707
|
-
const bin = tmuxBin(opts)
|
|
1708
|
-
const arg = String(effortArg || "auto").replace(/[\r\n]+/g, " ").trim()
|
|
1709
|
-
if (!arg) return { ok: false, error: "empty effort arg" }
|
|
1710
|
-
try {
|
|
1711
|
-
// copy-mode 等に入っているとキーが奪われるので、入っている時だけ抜ける。
|
|
1712
|
-
try {
|
|
1713
|
-
const { stdout } = await execFileP(bin, [
|
|
1714
|
-
"display-message",
|
|
1715
|
-
"-p",
|
|
1716
|
-
"-t",
|
|
1717
|
-
`${name}:`,
|
|
1718
|
-
"-F",
|
|
1719
|
-
"#{pane_in_mode}",
|
|
1720
|
-
])
|
|
1721
|
-
if (stdout.trim() === "1") {
|
|
1722
|
-
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1723
|
-
}
|
|
1724
|
-
} catch {
|
|
1725
|
-
// pane_in_mode 取得失敗はベストエフォートで無視。
|
|
1726
|
-
}
|
|
1727
|
-
// `/effort <level>` をリテラルで送る (-l でキー名解釈・スラッシュ補完の暴発を避ける)。
|
|
1728
|
-
await execFileP(bin, ["send-keys", "-t", name, "-l", `/effort ${arg}`])
|
|
1729
|
-
await _delay(120)
|
|
1730
|
-
// Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
|
|
1731
|
-
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1732
|
-
// prior output ありの場合に出る「Change effort level?」確認ダイアログを、描画を検知して
|
|
1733
|
-
// から Enter で畳む (setTuiModel と同じ堅牢化)。
|
|
1734
|
-
await _confirmSwitchDialog(bin, name, opts)
|
|
1735
|
-
return { ok: true }
|
|
1736
|
-
} catch (err) {
|
|
1737
|
-
opts.logger?.warn(
|
|
1738
|
-
{ session: name, effort: arg, err: err?.message },
|
|
1739
|
-
"setTuiEffort failed",
|
|
1740
|
-
)
|
|
1741
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
/**
|
|
1746
|
-
* 中断キャンセル後の入力欄復旧 (claude.tui.recoverInput / agent >= 0.6.57)。
|
|
1747
|
-
*
|
|
1748
|
-
* 対話 claude TUI は「送信直後の Esc」で直前メッセージの本文を入力ボックスへ戻す。
|
|
1749
|
-
* cockpit の TUI チャットはこれを関知できず、次の bracketed paste が戻された本文の
|
|
1750
|
-
* 後ろへ連結されて別メッセージが合体送信されるバグがあった (2026-06-08 報告。
|
|
1751
|
-
* AAA→キャンセル→BBB 送信が AAABBB になる)。
|
|
1752
|
-
*
|
|
1753
|
-
* 本関数は frontend が PTY 経由で Esc (中断) を送った直後に呼ばれ、ペインを短間隔で
|
|
1754
|
-
* 実測して「期待する本文 (expectText) が入力欄へ戻った」ことを確認してからクリアする:
|
|
1755
|
-
* 1. capturePane → detectInputBoxText で入力欄本文を抽出し、expectText と照合
|
|
1756
|
-
* (ペイン幅での折り返しを考慮して空白を正規化し、先頭 40 文字で部分照合)
|
|
1757
|
-
* 2. 在中確認後に Escape を送る。現行 claude (v2.1.16x 実機確認 2026-06-08) は
|
|
1758
|
-
* 「Esc again to clear」の 2 段階クリアなので、在中が続く限り短間隔で Escape を
|
|
1759
|
-
* 繰り返す (連続 2 打がヒント窓内に収まりクリアされる。旧版の 1 打クリアなら
|
|
1760
|
-
* 1 回目の後の照合で抜ける)。**毎回在中を確認してから打つ**ため、空入力への
|
|
1761
|
-
* Esc 連打 (= rewind ダイアログが開く) は起きない。
|
|
1762
|
-
* 3. C-c は使わない。C-c はクリアと同時に exit を 1 段プライムするため、frontend 側の
|
|
1763
|
-
* 旧 agent フォールバック (\x03) と近接すると 2 連 C-c で claude ごと終了し得る。
|
|
1764
|
-
*
|
|
1765
|
-
* deadline までに本文が現れなければ「復元は起きなかった」(= 応答開始後の通常中断や
|
|
1766
|
-
* 復元しない claude バージョン) として cleared:false を返す。ベストエフォートで throw しない。
|
|
1767
|
-
*
|
|
1768
|
-
* @param {string} name tmux セッション名
|
|
1769
|
-
* @param {string} expectText 入力ボックスへ戻っていると期待する本文 (キャンセルした送信本文)
|
|
1770
|
-
* @param {{logger?:object,tmuxBin?:string,deadlineMs?:number}} [opts]
|
|
1771
|
-
* @returns {Promise<{ok:boolean, cleared:boolean, error?:string}>}
|
|
1772
|
-
*/
|
|
1773
|
-
export async function recoverTuiInput(name, expectText, opts = {}) {
|
|
1774
|
-
const bin = tmuxBin(opts)
|
|
1775
|
-
// 折り返し (tmux はグリッド幅で行を割る) を跨いでも照合できるよう空白を全除去して
|
|
1776
|
-
// 正規化し、先頭 40 文字を needle にする (長文はボックス内で truncate され得るため)。
|
|
1777
|
-
const norm = (s) => String(s || "").replace(/\s+/g, "")
|
|
1778
|
-
const needle = norm(expectText).slice(0, 40)
|
|
1779
|
-
if (!needle) return { ok: true, cleared: false }
|
|
1780
|
-
const deadline = Date.now() + (opts.deadlineMs ?? 1500)
|
|
1781
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1782
|
-
const readBox = async () =>
|
|
1783
|
-
detectInputBoxText(await capturePane(name, { noCache: true }))
|
|
1784
|
-
const present = async () => {
|
|
1785
|
-
const box = await readBox()
|
|
1786
|
-
return !!box && norm(box).includes(needle)
|
|
1787
|
-
}
|
|
1788
|
-
try {
|
|
1789
|
-
while (Date.now() < deadline) {
|
|
1790
|
-
if (await present()) {
|
|
1791
|
-
// 在中確認 → Escape → 再確認、を短間隔で繰り返す (最大 4 打)。現行 claude の
|
|
1792
|
-
// 「Esc again to clear」窓 (>= 0.5s 実測) に 2 打目が収まりクリアされる。
|
|
1793
|
-
// 在中でなくなった時点で打鍵をやめるので、空入力への Esc は送られない。
|
|
1794
|
-
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1795
|
-
await execFileP(bin, ["send-keys", "-t", name, "Escape"])
|
|
1796
|
-
await sleep(200)
|
|
1797
|
-
if (!(await present())) {
|
|
1798
|
-
opts.logger?.info(
|
|
1799
|
-
{ session: name, len: expectText?.length, attempt },
|
|
1800
|
-
"tui recoverInput: cleared restored text",
|
|
1801
|
-
)
|
|
1802
|
-
return { ok: true, cleared: true }
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
// 4 打しても残っている = クリア不能 (想定外のバージョン差異)。状態を正直に返す。
|
|
1806
|
-
opts.logger?.warn(
|
|
1807
|
-
{ session: name },
|
|
1808
|
-
"tui recoverInput: text present but could not clear",
|
|
1809
|
-
)
|
|
1810
|
-
return { ok: true, cleared: false }
|
|
1811
|
-
}
|
|
1812
|
-
await sleep(120)
|
|
1813
|
-
}
|
|
1814
|
-
return { ok: true, cleared: false }
|
|
1815
|
-
} catch (err) {
|
|
1816
|
-
opts.logger?.warn(
|
|
1817
|
-
{ session: name, err: err?.message },
|
|
1818
|
-
"recoverTuiInput failed",
|
|
1819
|
-
)
|
|
1820
|
-
return { ok: false, cleared: false, error: err?.message || String(err) }
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
/**
|
|
1825
|
-
* tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
|
|
1826
|
-
* (T04784 TUI resume binding)。
|
|
1827
|
-
*
|
|
1828
|
-
* 旧実装 (Ctrl-C 連打で claude REPL を抜ける) は不安定だった: `claude --continue || claude`
|
|
1829
|
-
* の `|| claude` フォールバックや生成中の状態で REPL を抜けきれず、resume コマンドが
|
|
1830
|
-
* **動作中の claude の入力欄に文字列として打ち込まれて会話に混入**する事故が出た
|
|
1831
|
-
* (2026-06-06 finance 実機 / leftClaude:false でも送ってしまっていた)。
|
|
1832
|
-
*
|
|
1833
|
-
* 新実装は `tmux respawn-pane -k` でペインのプロセスを**原子的に kill → 既定シェルへ置換**し、
|
|
1834
|
-
* クリーンなシェルに `claude --resume <id> [flags]` を送る。文字列混入が起きず決定的。
|
|
1835
|
-
* respawn はペイン自体を残すので browser の隠し端末 (tmux attach) は繋がったまま、
|
|
1836
|
-
* かつ claude 終了後もシェルが残る (ペイン消滅でセッションが落ちるのを防ぐ)。
|
|
1837
|
-
*
|
|
1838
|
-
* ⚠️ 冪等性 (remount のたびに再起動しない) は呼び出し側 (main.mjs の
|
|
1839
|
-
* tuiReboundSessions マップ) で担保する。本関数は呼ばれたら必ず respawn する。
|
|
1840
|
-
*
|
|
1841
|
-
* ベストエフォート。失敗しても throw せず {ok:false} を返し、上位は newest 表示に degrade する。
|
|
1842
|
-
*
|
|
1843
|
-
* opts.fresh=true のときは sessionId を無視し、resume 無しの `claude` を起動して
|
|
1844
|
-
* まっさらな新規セッションにする (ピッカーの「+新規」)。
|
|
1845
|
-
*
|
|
1846
|
-
* @param {string} name tmux セッション名
|
|
1847
|
-
* @param {string|null} sessionId 載せ替え先 Claude session_id (opts.fresh 時は null 可)
|
|
1848
|
-
* @param {{cwd?:string,model?:string,permissionMode?:string,fresh?:boolean,logger?:object,tmuxBin?:string}} [opts]
|
|
1849
|
-
* @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
|
|
1850
|
-
*/
|
|
1851
|
-
/**
|
|
1852
|
-
* rebind (respawn-pane -k + claude --resume) を抑止すべきか判定する純ロジック。
|
|
1853
|
-
*
|
|
1854
|
-
* 背景 (謎停止対策): claude.tui.bind は TUI ビューの mount / 再接続のたびに来る。respawn-pane -k は
|
|
1855
|
-
* 現 claude を強制 kill するため、生成中に走ると in-flight の応答が失われる (jsonl 未確定)。冪等ガード
|
|
1856
|
-
* (tuiReboundSessions) は hub-agent 再起動直後は空で効かず、ブラウザ再接続の bind が生成中 claude を
|
|
1857
|
-
* kill する事故 (謎停止) を起こす。
|
|
1858
|
-
*
|
|
1859
|
-
* 抑止条件: 生成中 (generating) かつ「今動いているセッション (= 最新 jsonl = newestId) への再 bind」。
|
|
1860
|
-
* これは再接続/remount であり respawn は純粋に破壊的。逆に:
|
|
1861
|
-
* - fresh (+新規セッション要求) は明示操作なので respawn する。
|
|
1862
|
-
* - targetId !== newestId (別 session への明示 switch) は respawn する (生成中セッションは別ペイン文脈で温存)。
|
|
1863
|
-
* - 非生成中は従来どおり respawn する (idle claude の --resume 載せ替えは非破壊)。
|
|
1864
|
-
*
|
|
1865
|
-
* @param {{generating?: boolean, fresh?: boolean, targetId?: string|null, newestId?: string|null}} a
|
|
1866
|
-
* @returns {boolean}
|
|
1867
|
-
*/
|
|
1868
|
-
/**
|
|
1869
|
-
* ペインの前景プロセスが claude を実行中か(= respawn-pane -k で kill すると in-flight 応答を失う)
|
|
1870
|
-
* を pane_current_command から判定する。capture-pane スクレイプや armed(140s cap) の脆い推論に
|
|
1871
|
-
* 依存しない実プロセス由来の判定で、hub-agent 再起動・armed cap・capture gap を跨いで有効。
|
|
1872
|
-
* シェル(fish/bash/zsh/sh/dash/tmux/login)なら false。判定不能時は安全側 false(= respawn 許可)。
|
|
1873
|
-
*/
|
|
1874
|
-
export async function isPaneRunningClaude(name, opts = {}) {
|
|
1875
|
-
if (!name) return false
|
|
1876
|
-
try {
|
|
1877
|
-
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
1878
|
-
"display-message",
|
|
1879
|
-
"-p",
|
|
1880
|
-
"-t",
|
|
1881
|
-
`${name}:`,
|
|
1882
|
-
"-F",
|
|
1883
|
-
"#{pane_current_command}",
|
|
1884
|
-
])
|
|
1885
|
-
const cmd = (stdout || "").trim().toLowerCase()
|
|
1886
|
-
if (!cmd) return false
|
|
1887
|
-
const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash", "tmux", "login"])
|
|
1888
|
-
// TUI チャットのペインで走るのは claude(claude / claude.exe / node 等)かシェルのいずれか。
|
|
1889
|
-
// シェルでない前景プロセス = claude 実行中とみなす(命名差 claude.exe/node に強い)。
|
|
1890
|
-
return !SHELLS.has(cmd)
|
|
1891
|
-
} catch {
|
|
1892
|
-
return false
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
1222
|
/**
|
|
1897
1223
|
* pane_current_command から claude プロセスの生存を 3 値で返す (T2)。
|
|
1898
1224
|
*
|
|
1899
|
-
* `
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
* 生存 ('alive') では active を維持する」という symmetry が逆向きの設計が必要で、
|
|
1904
|
-
* 既存 isPaneRunningClaude の意味論を再利用すると tmux 失敗 → false → 誤って idle 化
|
|
1905
|
-
* = 偽陰性増加 = 中断後の長時間ツール中ペインを灰化させてしまう。
|
|
1225
|
+
* T2 (`scanTurnActive` の tool_use 末尾クラッシュ判定) は「claude が確実に死んだ ('dead')
|
|
1226
|
+
* ときだけ active=false に倒し、判定不能 ('unknown') / 生存 ('alive') では active を維持する」
|
|
1227
|
+
* 設計。判定不能を false (= 死んだ扱い) に倒す 2 値版だと、tmux 失敗 → 誤って idle 化
|
|
1228
|
+
* = 偽陰性増加 = 中断後の長時間ツール中ペインを灰化させてしまうため、3 値で返す。
|
|
1906
1229
|
*
|
|
1907
1230
|
* 3 値の意味:
|
|
1908
1231
|
* - 'dead' : pane の前景がシェル (fish/bash/zsh/sh/dash/tmux/login) = claude プロセス
|
|
@@ -1910,9 +1233,7 @@ export async function isPaneRunningClaude(name, opts = {}) {
|
|
|
1910
1233
|
* - 'alive' : pane の前景がシェル以外 (claude / claude.exe / node 等) = 生存
|
|
1911
1234
|
* - 'unknown' : tmux 失敗 / 空出力 = 判定不能 = 安全側で active 維持に倒す
|
|
1912
1235
|
*
|
|
1913
|
-
*
|
|
1914
|
-
* 独立 export とする)。命名 (`AliveOrUnknown`) も呼び出し側で「3 値である」ことを
|
|
1915
|
-
* 明示するため。
|
|
1236
|
+
* 命名 (`AliveOrUnknown`) は呼び出し側で「3 値である」ことを明示するため。
|
|
1916
1237
|
*
|
|
1917
1238
|
* @param {string} name
|
|
1918
1239
|
* @param {{ tmux?: string }} [opts]
|
|
@@ -1938,84 +1259,6 @@ export async function paneClaudeAliveOrUnknown(name, opts = {}) {
|
|
|
1938
1259
|
}
|
|
1939
1260
|
}
|
|
1940
1261
|
|
|
1941
|
-
export function shouldSkipRebindRespawn({
|
|
1942
|
-
generating,
|
|
1943
|
-
fresh,
|
|
1944
|
-
targetId,
|
|
1945
|
-
newestId,
|
|
1946
|
-
paneRunningClaude,
|
|
1947
|
-
busy = false,
|
|
1948
|
-
} = {}) {
|
|
1949
|
-
// 【最優先・無条件の安全弁 — 構造版 根治 2026-06-22】ペインが現にターン進行中 (busy) なら、
|
|
1950
|
-
// fresh / resume / switch のいずれであっても respawn-pane -k で実行中 claude を kill しない。
|
|
1951
|
-
// busy は呼び出し側がプロセス実体 (ReadinessTracker.isArmed = prompt_submit→ready の間、
|
|
1952
|
-
// または pane_pid 子孫数 > 学習 baseline) で算出する堅い信号で、capture スクレイプや
|
|
1953
|
-
// armed 140s cap の脆さに依存しない。これにより「動いている claude は誰の要求でも殺さない」を
|
|
1954
|
-
// 唯一の破壊点 (rebindClaudeSession) の手前で構造的に保証する。
|
|
1955
|
-
// 旧バグ: `if (fresh) return false` が fresh:true を無条件に respawn(=kill) へ通し、防御を完全に
|
|
1956
|
-
// frontend (fresh を送らない前提) 単独依存にしていた。frontend の 1 経路でも fresh が漏れると
|
|
1957
|
-
// 生成中 claude が死ぬ事故 (症状D) が再発した。busy ゲートで前提依存を断つ。
|
|
1958
|
-
// fresh の +新規 要求が busy で抑止された場合、呼び出し側は busy 解消 (ready) まで defer する。
|
|
1959
|
-
if (busy) return true
|
|
1960
|
-
if (fresh) return false
|
|
1961
|
-
// 実行中 claude を kill しない補助ガード: ペインが現に claude を実行中で、最新セッション
|
|
1962
|
-
// (= 今動いている会話)への再 bind(remount/reconnect)なら respawn は破壊的なだけで不要。
|
|
1963
|
-
// busy が false に倒れても (cap/未観測)、pane_current_command 由来の paneRunningClaude が守る。
|
|
1964
|
-
if (paneRunningClaude && (!targetId || targetId === newestId)) return true
|
|
1965
|
-
if (!generating) return false
|
|
1966
|
-
return !!targetId && targetId === newestId
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
1970
|
-
// 【破壊点チョークポイント — 最終安全弁 2026-06-22】respawn-pane -k を実行する唯一の関数。
|
|
1971
|
-
// busy=true (ターン進行中) なら shouldSkipRebindRespawn を経由せず直接呼ばれても respawn を
|
|
1972
|
-
// 絶対に発行しない。これで「動いている claude を kill しない」不変条件を実行地点 1 箇所で
|
|
1973
|
-
// 保証する (呼び出し側の判定漏れに対する多層防御)。呼び出し側が ready まで defer する。
|
|
1974
|
-
if (opts.busy === true) {
|
|
1975
|
-
opts.logger?.info(
|
|
1976
|
-
{ session: name, fresh: !!opts.fresh },
|
|
1977
|
-
"tui rebind: skipped respawn at chokepoint (pane busy, deferred)",
|
|
1978
|
-
)
|
|
1979
|
-
return { ok: false, skipped: true, reason: "busy" }
|
|
1980
|
-
}
|
|
1981
|
-
let cmd
|
|
1982
|
-
try {
|
|
1983
|
-
// fresh=新規起動 (--resume 無し)。それ以外は session_id 検証込みの resume。
|
|
1984
|
-
// cliKind に応じて claude / codex のビルダを使い分ける (既定は claude)。
|
|
1985
|
-
if (opts.cliKind === "codex") {
|
|
1986
|
-
cmd = opts.fresh ? buildCodexFreshCmd(opts) : buildCodexResumeCmd(sessionId, opts)
|
|
1987
|
-
} else {
|
|
1988
|
-
cmd = opts.fresh ? buildFreshCmd(opts) : buildResumeCmd(sessionId, opts)
|
|
1989
|
-
}
|
|
1990
|
-
} catch (err) {
|
|
1991
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1992
|
-
}
|
|
1993
|
-
const bin = tmuxBin(opts)
|
|
1994
|
-
try {
|
|
1995
|
-
// 1) ペインを既定シェルへ原子置換 (-k で現 claude を強制 kill)。コマンド未指定なので
|
|
1996
|
-
// クリーンなシェルが起動する。-c でセッションの cwd を維持。
|
|
1997
|
-
const respArgs = ["respawn-pane", "-k", "-t", name]
|
|
1998
|
-
if (opts.cwd) respArgs.push("-c", opts.cwd)
|
|
1999
|
-
await execFileP(bin, respArgs)
|
|
2000
|
-
// 2) シェル準備待ち (rc 読み込み等)。send-keys は pty にバッファされるので過敏でなくてよい。
|
|
2001
|
-
await _delay(300)
|
|
2002
|
-
// 3) クリーンなシェルに claude 起動コマンドを送る (resume or 新規)。
|
|
2003
|
-
await execFileP(bin, ["send-keys", "-t", name, cmd, "Enter"])
|
|
2004
|
-
opts.logger?.info(
|
|
2005
|
-
{ session: name, resume: opts.fresh ? null : sessionId, fresh: !!opts.fresh },
|
|
2006
|
-
opts.fresh
|
|
2007
|
-
? "tui rebind: respawned pane shell + claude (fresh session)"
|
|
2008
|
-
: "tui rebind: respawned pane shell + claude --resume",
|
|
2009
|
-
)
|
|
2010
|
-
return { ok: true, cmd }
|
|
2011
|
-
} catch (err) {
|
|
2012
|
-
opts.logger?.warn(
|
|
2013
|
-
{ session: name, resume: sessionId, err: err?.message },
|
|
2014
|
-
"tui rebind failed",
|
|
2015
|
-
)
|
|
2016
|
-
return { ok: false, error: err?.message || String(err) }
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
1262
|
|
|
2020
1263
|
// ---------------------------------------------------------------------------
|
|
2021
1264
|
// ターミナルミラー (Cockpit Codex 両対応 Stream C, 2026-07-03)
|
|
@@ -2083,7 +1326,7 @@ export function buildMirrorStopCmd({ sessionName }) {
|
|
|
2083
1326
|
}
|
|
2084
1327
|
|
|
2085
1328
|
/**
|
|
2086
|
-
* 対象ペインの前景が plain shell か判定する (`
|
|
1329
|
+
* 対象ペインの前景が plain shell か判定する (`paneClaudeAliveOrUnknown` の逆方向・2 値版)。
|
|
2087
1330
|
* 判定不能 (tmux 失敗等) は false を返す = 起動を許可しない安全側。
|
|
2088
1331
|
*
|
|
2089
1332
|
* @param {string} name
|