@cocorograph/hub-agent 0.7.21 → 0.7.23
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 +31 -1
- package/src/main.mjs +40 -0
- package/src/tmux.mjs +238 -0
- package/src/tui-permission-bridge.mjs +3 -0
package/package.json
CHANGED
|
@@ -31,10 +31,25 @@ const _TOOL_CALL_RE = /^[A-Z][A-Za-z0-9_]*\s*\(/
|
|
|
31
31
|
|
|
32
32
|
const _BOX_LINE_RE = /^[─-▟]/
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// 状態 / スピナー行。Claude 2.1.x は「考え中」スピナーに ✻ ✳ ✽ ✶ ✵ 等の星形グリフを
|
|
35
|
+
// 循環使用する (例: "✻ Sautéed for 2s" / "✳ Frolicking… (3s · ↓ 34 tokens)")。説明段落に
|
|
36
|
+
// 紛れ込ませないよう除外グリフを広く取る (旧版は ✨✹●⚫◯ のみで ✻✳✽ を取りこぼしていた)。
|
|
37
|
+
const _STATUS_LINE_RE = /^[✨✹✺✻✷✸✹✳✲✱✽✶✵✴✦✧⋆●⚫◯◉]\s/
|
|
35
38
|
|
|
36
39
|
const _ELLIPSIS_TAIL_RE = /\n[ \t]*…\s*\+\d+\s+lines[^\n]*$/
|
|
37
40
|
|
|
41
|
+
// stop hook サマリ行。Claude 2.1.x は "Ran N stop hooks (ctrl+o to expand)" を
|
|
42
|
+
// アシスタント本文と同じ ⏺ プレフィックス付きで描画するため、ツール呼び出しと同様に
|
|
43
|
+
// 「説明段落の始点」候補から除外する (誤って始点にすると hook エラー出力を巻き込む)。
|
|
44
|
+
const _STOP_HOOK_SUMMARY_RE = /^Ran\s+\d+\s+stop\s+hooks?\b/i
|
|
45
|
+
|
|
46
|
+
// ツール結果 / フック結果の継続行 (⎿)。説明段落の途中で現れたら打ち切る。
|
|
47
|
+
const _TOOL_RESULT_RE = /^\s*⎿/
|
|
48
|
+
|
|
49
|
+
// 入力欄に映るユーザープロンプトのエコー (❯ + 本文)。メニュー選択肢 (❯ 1. ...) と
|
|
50
|
+
// 空の入力欄 (❯ のみ) は除外する。これがある = ターン境界の目印として使う。
|
|
51
|
+
const _INPUT_ECHO_RE = /^❯\s+(?!\d+\.\s)\S/
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* @param {string|undefined|null} paneText capturePane の出力 (ANSI 除去済み)
|
|
40
55
|
* @returns {string|null} 直近のアシスタント説明 (失敗時 null)
|
|
@@ -49,11 +64,20 @@ export function extractLastAssistantText(paneText) {
|
|
|
49
64
|
if (!line.startsWith(ASSIST_PREFIX + " ")) continue
|
|
50
65
|
const rest = line.slice(ASSIST_PREFIX.length + 1).trimStart()
|
|
51
66
|
if (_TOOL_CALL_RE.test(rest)) continue
|
|
67
|
+
if (_STOP_HOOK_SUMMARY_RE.test(rest)) continue
|
|
52
68
|
startIdx = i
|
|
53
69
|
break
|
|
54
70
|
}
|
|
55
71
|
if (startIdx < 0) return null
|
|
56
72
|
|
|
73
|
+
// 前ターン取り違え防止: startIdx より下 (= より新しい) に現ターンのユーザープロンプト
|
|
74
|
+
// エコーがあるなら、その ⏺ は前ターンの説明である。現ターンはツールへ直行 (説明文なし) と
|
|
75
|
+
// 判断し null へ縮退する (古い説明を出すより「説明なし」が安全。capture-pane が処理中の
|
|
76
|
+
// ノイズだらけのペインを撮ったときに前ターン本文や hook 出力を掴むのを防ぐ)。
|
|
77
|
+
for (let j = startIdx + 1; j < lines.length; j++) {
|
|
78
|
+
if (_INPUT_ECHO_RE.test(lines[j])) return null
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
const parts = []
|
|
58
82
|
let consecBlank = 0
|
|
59
83
|
for (let i = startIdx; i < lines.length; i++) {
|
|
@@ -62,6 +86,8 @@ export function extractLastAssistantText(paneText) {
|
|
|
62
86
|
if (line.startsWith(ASSIST_PREFIX + " ")) break
|
|
63
87
|
if (_BOX_LINE_RE.test(line)) break
|
|
64
88
|
if (_STATUS_LINE_RE.test(line.trim())) break
|
|
89
|
+
if (_TOOL_RESULT_RE.test(line)) break
|
|
90
|
+
if (_INPUT_ECHO_RE.test(line)) break
|
|
65
91
|
}
|
|
66
92
|
if (!line.trim()) {
|
|
67
93
|
consecBlank++
|
|
@@ -116,4 +142,8 @@ export const __test = {
|
|
|
116
142
|
_MIN_CHARS,
|
|
117
143
|
_TOOL_CALL_RE,
|
|
118
144
|
_BOX_LINE_RE,
|
|
145
|
+
_STATUS_LINE_RE,
|
|
146
|
+
_STOP_HOOK_SUMMARY_RE,
|
|
147
|
+
_TOOL_RESULT_RE,
|
|
148
|
+
_INPUT_ECHO_RE,
|
|
119
149
|
}
|
package/src/main.mjs
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
swapCredentials,
|
|
61
61
|
} from "./claude-credentials.mjs"
|
|
62
62
|
import {
|
|
63
|
+
answerAskUserQuestion,
|
|
63
64
|
buildClaudeCmd,
|
|
64
65
|
createSession as createTmuxSession,
|
|
65
66
|
createWorktreeDir,
|
|
@@ -2422,6 +2423,45 @@ async function dispatch(msg, ctx) {
|
|
|
2422
2423
|
})()
|
|
2423
2424
|
return
|
|
2424
2425
|
}
|
|
2426
|
+
case "claude.tui.answerAskUserQuestion": {
|
|
2427
|
+
// 症状1a 根治 (1-B): ブラウザの AskUserQuestion カード回答を、フックの updatedInput
|
|
2428
|
+
// (AskUserQuestion では原理的に効かない) でなく、agent が同一ホストの tmux ネイティブ
|
|
2429
|
+
// メニューへ send-keys で届ける。capturePane で描画検知してから送出し状態変化を検証する
|
|
2430
|
+
// (blind 送出禁止)。回答後 claude が tool_result を commit → JsonlLiveWatchers が検知して
|
|
2431
|
+
// dropBySession → claude.permission.cancel でカードを畳む (1-A 経路と合成)。
|
|
2432
|
+
const sessionName =
|
|
2433
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
2434
|
+
const requestId =
|
|
2435
|
+
typeof msg.request_id === "string" ? msg.request_id : undefined
|
|
2436
|
+
// answers: 質問順の [{question?, labels: string[]}]。frontend が card の {questions, answers}
|
|
2437
|
+
// から構築する。labels は選んだ選択肢ラベル群 (multiSelect は複数、single は1件)。
|
|
2438
|
+
const answers = Array.isArray(msg.answers) ? msg.answers : []
|
|
2439
|
+
if (!sessionName) return
|
|
2440
|
+
const reply = (payload) =>
|
|
2441
|
+
ctx.client.send({
|
|
2442
|
+
type: "claude.tui.answerAskUserQuestion.result",
|
|
2443
|
+
request_id: requestId,
|
|
2444
|
+
session_name: sessionName,
|
|
2445
|
+
...payload,
|
|
2446
|
+
})
|
|
2447
|
+
;(async () => {
|
|
2448
|
+
try {
|
|
2449
|
+
const r = await answerAskUserQuestion(sessionName, answers, { logger })
|
|
2450
|
+
logger.info(
|
|
2451
|
+
{ session: sessionName, ok: r.ok, answered: r.answered, error: r.error },
|
|
2452
|
+
"tui answerAskUserQuestion via send-keys",
|
|
2453
|
+
)
|
|
2454
|
+
reply({ ok: !!r.ok, answered: r.answered ?? 0, error: r.error })
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
logger.warn(
|
|
2457
|
+
{ err: err?.message, session: sessionName },
|
|
2458
|
+
"claude.tui.answerAskUserQuestion failed",
|
|
2459
|
+
)
|
|
2460
|
+
reply({ ok: false, error: err?.message || String(err) })
|
|
2461
|
+
}
|
|
2462
|
+
})()
|
|
2463
|
+
return
|
|
2464
|
+
}
|
|
2425
2465
|
case "claude.tui.probePermission": {
|
|
2426
2466
|
// 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
|
|
2427
2467
|
// 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
|
package/src/tmux.mjs
CHANGED
|
@@ -1048,6 +1048,244 @@ export async function resumeWithMessage(name, text, opts = {}) {
|
|
|
1048
1048
|
}
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
|
+
// ── AskUserQuestion / ExitPlanMode ネイティブメニューへの send-keys 回答 (症状1a 根治, 1-B) ──
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* メニュー描画の anchor。通常の選択画面 footer ("Enter to select") に加え、最終確定画面
|
|
1055
|
+
* ("❯ 1. Submit answers"。footer 文言が異なる可能性があるため "Submit answers" でも拾う) を
|
|
1056
|
+
* 取りこぼさない。anchor がマッチしても option/submit 行が無ければ parseAskMenu は null を返す。
|
|
1057
|
+
*/
|
|
1058
|
+
const ASK_MENU_FOOTER_RE = /Enter to (?:select|confirm)|Submit answers/i
|
|
1059
|
+
/** 選択肢行: "❯? N. [✔]? label"。先頭 ❯ = カーソル、[ ]/[✔] = multiSelect チェックボックス。 */
|
|
1060
|
+
const ASK_OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(\[\s*([ xX✔✓])\s*\]\s+)?(.*\S)\s*$/
|
|
1061
|
+
/** 番号なしの "Submit" 行 (multiSelect の確定行)。 */
|
|
1062
|
+
const ASK_SUBMIT_ROW_RE = /^\s*(❯)?\s*Submit\s*$/
|
|
1063
|
+
const ASK_TYPE_SOMETHING_RE = /Type something/i
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* capturePane テキストから AskUserQuestion ネイティブメニューを構造化する (純関数, テスト用に export)。
|
|
1067
|
+
* メニュー未描画なら null。複数質問では「現在表示中の質問」の選択肢のみが見える。
|
|
1068
|
+
* @param {string} paneText capturePane の出力 (ANSI 除去済み)
|
|
1069
|
+
* @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}
|
|
1070
|
+
*/
|
|
1071
|
+
export function parseAskMenu(paneText) {
|
|
1072
|
+
if (typeof paneText !== "string" || !ASK_MENU_FOOTER_RE.test(paneText)) {
|
|
1073
|
+
return null
|
|
1074
|
+
}
|
|
1075
|
+
const lines = paneText.replace(/\r/g, "").split("\n")
|
|
1076
|
+
const options = []
|
|
1077
|
+
let submitRow = false
|
|
1078
|
+
let submitRowCursor = false
|
|
1079
|
+
let multiSelect = false
|
|
1080
|
+
for (const line of lines) {
|
|
1081
|
+
const m = ASK_OPTION_RE.exec(line)
|
|
1082
|
+
if (m) {
|
|
1083
|
+
const hasCheckbox = m[3] !== undefined
|
|
1084
|
+
if (hasCheckbox) multiSelect = true
|
|
1085
|
+
const label = m[5].trim()
|
|
1086
|
+
options.push({
|
|
1087
|
+
num: Number(m[2]),
|
|
1088
|
+
cursor: !!m[1],
|
|
1089
|
+
checked: hasCheckbox ? /[xX✔✓]/.test(m[4]) : null,
|
|
1090
|
+
label,
|
|
1091
|
+
isCustom: ASK_TYPE_SOMETHING_RE.test(label),
|
|
1092
|
+
isSubmitAnswers: /^Submit answers$/i.test(label),
|
|
1093
|
+
})
|
|
1094
|
+
continue
|
|
1095
|
+
}
|
|
1096
|
+
const sm = ASK_SUBMIT_ROW_RE.exec(line)
|
|
1097
|
+
if (sm) {
|
|
1098
|
+
submitRow = true
|
|
1099
|
+
if (sm[1]) submitRowCursor = true
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (options.length === 0 && !submitRow) return null
|
|
1103
|
+
return {
|
|
1104
|
+
options,
|
|
1105
|
+
submitRow,
|
|
1106
|
+
submitRowCursor,
|
|
1107
|
+
submitAnswers: options.some((o) => o.isSubmitAnswers),
|
|
1108
|
+
multiSelect,
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** ラベル一致: 完全 → 前方 → 含む の順 (claude のラベル truncate / 表記揺れ保険)。 */
|
|
1113
|
+
function _findAskOption(options, label) {
|
|
1114
|
+
const want = String(label || "").trim()
|
|
1115
|
+
if (!want) return null
|
|
1116
|
+
const pool = options.filter((o) => !o.isSubmitAnswers)
|
|
1117
|
+
return (
|
|
1118
|
+
pool.find((o) => o.label === want) ||
|
|
1119
|
+
pool.find((o) => o.label.startsWith(want) || want.startsWith(o.label)) ||
|
|
1120
|
+
pool.find((o) => o.label.includes(want) || want.includes(o.label)) ||
|
|
1121
|
+
null
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** 現在の質問シグネチャ (選択肢ラベル集合)。質問遷移 / 消失の検知に使う。 */
|
|
1126
|
+
function _askMenuSig(menu) {
|
|
1127
|
+
if (!menu) return null
|
|
1128
|
+
return (
|
|
1129
|
+
menu.options.map((o) => o.label).join("|") +
|
|
1130
|
+
(menu.submitAnswers ? "|SA" : "")
|
|
1131
|
+
)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* AskUserQuestion / ExitPlanMode のネイティブメニューへ実キーを送って回答する (1-B)。
|
|
1136
|
+
*
|
|
1137
|
+
* ブラウザのカード回答を、フックの updatedInput (AskUserQuestion では原理的に効かない) でなく
|
|
1138
|
+
* 同一ホストの tmux メニューへ send-keys で届ける。_confirmSwitchDialog と同じく capturePane で
|
|
1139
|
+
* 描画を検知してから送出し、状態変化を検証する (blind 送出はしない = 不変条件 B)。
|
|
1140
|
+
*
|
|
1141
|
+
* スパイク確定 (claude 2.1.195):
|
|
1142
|
+
* - single-select: 数字 N で即選択+確定 → 次質問へ自動遷移
|
|
1143
|
+
* - multiSelect: 数字 N でトグル → Submit 行へ ↓ → Enter
|
|
1144
|
+
* - 複数質問: 各質問回答で自動遷移 → 最終 "Submit answers" → Enter
|
|
1145
|
+
* - 自由入力: Type something の番号 → テキストモード → -l "<本文>" → Enter
|
|
1146
|
+
*
|
|
1147
|
+
* @param {string} name tmux セッション名
|
|
1148
|
+
* @param {Array<{question?:string, labels:string[]}>} answers 質問順の回答 (labels = 選んだ選択肢ラベル群)
|
|
1149
|
+
* @param {{logger?:object,tmuxBin?:string,appearMs?:number,pollMs?:number,stepMs?:number,dismissMs?:number}} [opts]
|
|
1150
|
+
* @returns {Promise<{ok:boolean, error?:string, answered:number}>}
|
|
1151
|
+
*/
|
|
1152
|
+
export async function answerAskUserQuestion(name, answers, opts = {}) {
|
|
1153
|
+
if (!isSafeSessionName(name)) {
|
|
1154
|
+
return { ok: false, error: "unsafe session name", answered: 0 }
|
|
1155
|
+
}
|
|
1156
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
1157
|
+
return { ok: false, error: "no answers", answered: 0 }
|
|
1158
|
+
}
|
|
1159
|
+
const bin = tmuxBin(opts)
|
|
1160
|
+
const appearMs = opts.appearMs ?? 4000
|
|
1161
|
+
const pollMs = opts.pollMs ?? 150
|
|
1162
|
+
const stepMs = opts.stepMs ?? 200
|
|
1163
|
+
const dismissMs = opts.dismissMs ?? 3000
|
|
1164
|
+
const cap = () => capturePane(name, { ...opts, noCache: true })
|
|
1165
|
+
|
|
1166
|
+
// copy-mode に入っているとキーが奪われるので先に抜ける (cyclePermission と同じ防御)。
|
|
1167
|
+
try {
|
|
1168
|
+
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
1169
|
+
} catch {
|
|
1170
|
+
/* not in a mode = 無視 */
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// メニュー出現待ち (フック即 abstain 直後はレンダ遅延がある)。
|
|
1174
|
+
const APPEAR_DEADLINE = Date.now() + appearMs
|
|
1175
|
+
let menu = parseAskMenu(await cap())
|
|
1176
|
+
while (!menu && Date.now() < APPEAR_DEADLINE) {
|
|
1177
|
+
await _delay(pollMs)
|
|
1178
|
+
menu = parseAskMenu(await cap())
|
|
1179
|
+
}
|
|
1180
|
+
if (!menu) return { ok: false, error: "ask menu not visible", answered: 0 }
|
|
1181
|
+
|
|
1182
|
+
// 現在の質問が「変わる(=遷移) / 消える」まで待つ。timeout でも先へ進む (best-effort)。
|
|
1183
|
+
const waitChanged = async (prevSig) => {
|
|
1184
|
+
const dl = Date.now() + dismissMs
|
|
1185
|
+
while (Date.now() < dl) {
|
|
1186
|
+
const sig = _askMenuSig(parseAskMenu(await cap()))
|
|
1187
|
+
if (sig !== prevSig) return
|
|
1188
|
+
await _delay(pollMs)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
let answered = 0
|
|
1193
|
+
const maxIters = answers.length * 3 + 8
|
|
1194
|
+
for (let guard = 0; guard < maxIters; guard++) {
|
|
1195
|
+
menu = parseAskMenu(await cap())
|
|
1196
|
+
if (!menu) return { ok: true, answered } // 全回答完了 (single-q single-select 等)
|
|
1197
|
+
|
|
1198
|
+
// "Submit answers" 確認画面 → 確定して次状態へ。
|
|
1199
|
+
const sa = menu.options.find((o) => o.isSubmitAnswers)
|
|
1200
|
+
if (sa) {
|
|
1201
|
+
const sig = _askMenuSig(menu)
|
|
1202
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(sa.num)])
|
|
1203
|
+
await waitChanged(sig)
|
|
1204
|
+
continue
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const entry = answers[answered]
|
|
1208
|
+
if (!entry) return { ok: false, error: "more questions than answers", answered }
|
|
1209
|
+
const labels = Array.isArray(entry.labels) ? entry.labels : []
|
|
1210
|
+
const sigBefore = _askMenuSig(menu)
|
|
1211
|
+
|
|
1212
|
+
if (menu.multiSelect) {
|
|
1213
|
+
// 各ラベルをトグル (既にチェック済みは触らない)。
|
|
1214
|
+
for (const label of labels) {
|
|
1215
|
+
const m2 = parseAskMenu(await cap())
|
|
1216
|
+
if (!m2) break
|
|
1217
|
+
const opt = _findAskOption(m2.options, label)
|
|
1218
|
+
if (opt && opt.checked !== true) {
|
|
1219
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
|
|
1220
|
+
await _delay(stepMs)
|
|
1221
|
+
} else if (!opt) {
|
|
1222
|
+
// 一致なし → 自由入力で補う (best-effort)。
|
|
1223
|
+
const custom = m2.options.find((o) => o.isCustom)
|
|
1224
|
+
if (custom) {
|
|
1225
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
|
|
1226
|
+
await _delay(stepMs)
|
|
1227
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(label)])
|
|
1228
|
+
await _delay(stepMs)
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// Submit 行へカーソル移動して Enter (↓ を送りながら検証)。
|
|
1233
|
+
const navDl = Date.now() + dismissMs
|
|
1234
|
+
let submitted = false
|
|
1235
|
+
while (Date.now() < navDl) {
|
|
1236
|
+
const m3 = parseAskMenu(await cap())
|
|
1237
|
+
if (!m3 || m3.submitAnswers) {
|
|
1238
|
+
submitted = true
|
|
1239
|
+
break
|
|
1240
|
+
}
|
|
1241
|
+
if (m3.submitRowCursor) {
|
|
1242
|
+
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1243
|
+
await _delay(stepMs)
|
|
1244
|
+
submitted = true
|
|
1245
|
+
break
|
|
1246
|
+
}
|
|
1247
|
+
await execFileP(bin, ["send-keys", "-t", name, "Down"])
|
|
1248
|
+
await _delay(120)
|
|
1249
|
+
}
|
|
1250
|
+
if (!submitted) {
|
|
1251
|
+
return { ok: false, error: "multiSelect submit nav failed", answered }
|
|
1252
|
+
}
|
|
1253
|
+
} else {
|
|
1254
|
+
// single-select: ラベル一致の数字で即確定 (+自動遷移)。
|
|
1255
|
+
const label = labels[0]
|
|
1256
|
+
const opt = _findAskOption(menu.options, label)
|
|
1257
|
+
if (opt) {
|
|
1258
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
|
|
1259
|
+
} else {
|
|
1260
|
+
const custom = menu.options.find((o) => o.isCustom)
|
|
1261
|
+
if (!custom) return { ok: false, error: `option not found: ${label}`, answered }
|
|
1262
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
|
|
1263
|
+
await _delay(stepMs)
|
|
1264
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", String(label || "")])
|
|
1265
|
+
await _delay(stepMs)
|
|
1266
|
+
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
answered++
|
|
1270
|
+
await waitChanged(sigBefore) // 質問遷移 / 消失を確認してから次へ (二重回答防止)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// 最後に "Submit answers" が残っていれば確定し、メニュー消失を検証。
|
|
1274
|
+
const finalDl = Date.now() + dismissMs
|
|
1275
|
+
while (Date.now() < finalDl) {
|
|
1276
|
+
const m = parseAskMenu(await cap())
|
|
1277
|
+
if (!m) return { ok: true, answered }
|
|
1278
|
+
const sa = m.options.find((o) => o.isSubmitAnswers)
|
|
1279
|
+
if (sa) await execFileP(bin, ["send-keys", "-t", name, "-l", String(sa.num)])
|
|
1280
|
+
await _delay(180)
|
|
1281
|
+
}
|
|
1282
|
+
opts.logger?.warn(
|
|
1283
|
+
{ session: name, answered },
|
|
1284
|
+
"answerAskUserQuestion: menu still visible after retries",
|
|
1285
|
+
)
|
|
1286
|
+
return { ok: true, answered }
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1051
1289
|
/**
|
|
1052
1290
|
* 対話 TUI claude へ中断 (Esc) を tmux send-keys で送る。PTY stream に依存しないため、
|
|
1053
1291
|
* zombie WS / stream 欠落窓でも確実に届く (生 ESC を tracked pty.data で送る旧経路は stream
|
|
@@ -178,6 +178,9 @@ export class TuiPermissionBridge extends EventEmitter {
|
|
|
178
178
|
if (toolName && payload.tool_name !== toolName) continue
|
|
179
179
|
this._pending.delete(request_id)
|
|
180
180
|
this._seen.delete(request_id)
|
|
181
|
+
// 要求ファイルも掃除する (1-B: 質問系フックは即 abstain して .json を残すため、
|
|
182
|
+
// 畳んだ後に消さないと agent 再起動時の _sweep が古いカードを再 emit する)。best-effort。
|
|
183
|
+
unlink(path.join(this.dir, `${request_id}.json`)).catch(() => {})
|
|
181
184
|
dropped.push(request_id)
|
|
182
185
|
}
|
|
183
186
|
return dropped
|