@cocorograph/hub-agent 0.7.22 → 0.7.24

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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,252 @@ 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
+ // 全回答済みなのにメニューが残る = 直前回答の反映ラグ (特に自由入力で Type something の
1208
+ // ラベルが入力文字へ変わり waitChanged が早期復帰した場合)。ここで「質問が余っている」と
1209
+ // 誤判定して ok:false を返すと frontend が回答済みカードを復元してしまう。完了待ち
1210
+ // (final block) へ抜けてメニュー消失を確認する。
1211
+ if (answered >= answers.length) break
1212
+ const entry = answers[answered]
1213
+ const labels = Array.isArray(entry.labels) ? entry.labels : []
1214
+ const sigBefore = _askMenuSig(menu)
1215
+
1216
+ if (menu.multiSelect) {
1217
+ // 各ラベルをトグル (既にチェック済みは触らない)。
1218
+ for (const label of labels) {
1219
+ const m2 = parseAskMenu(await cap())
1220
+ if (!m2) break
1221
+ const opt = _findAskOption(m2.options, label)
1222
+ if (opt && opt.checked !== true) {
1223
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
1224
+ await _delay(stepMs)
1225
+ } else if (!opt) {
1226
+ // 一致なし → 自由入力で補う (best-effort)。
1227
+ const custom = m2.options.find((o) => o.isCustom)
1228
+ if (custom) {
1229
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
1230
+ await _delay(stepMs)
1231
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(label)])
1232
+ await _delay(stepMs)
1233
+ }
1234
+ }
1235
+ }
1236
+ // Submit 行へカーソル移動して Enter (↓ を送りながら検証)。
1237
+ const navDl = Date.now() + dismissMs
1238
+ let submitted = false
1239
+ while (Date.now() < navDl) {
1240
+ const m3 = parseAskMenu(await cap())
1241
+ if (!m3 || m3.submitAnswers) {
1242
+ submitted = true
1243
+ break
1244
+ }
1245
+ if (m3.submitRowCursor) {
1246
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1247
+ await _delay(stepMs)
1248
+ submitted = true
1249
+ break
1250
+ }
1251
+ await execFileP(bin, ["send-keys", "-t", name, "Down"])
1252
+ await _delay(120)
1253
+ }
1254
+ if (!submitted) {
1255
+ return { ok: false, error: "multiSelect submit nav failed", answered }
1256
+ }
1257
+ } else {
1258
+ // single-select: ラベル一致の数字で即確定 (+自動遷移)。
1259
+ const label = labels[0]
1260
+ const opt = _findAskOption(menu.options, label)
1261
+ if (opt) {
1262
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(opt.num)])
1263
+ } else {
1264
+ const custom = menu.options.find((o) => o.isCustom)
1265
+ if (!custom) return { ok: false, error: `option not found: ${label}`, answered }
1266
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(custom.num)])
1267
+ await _delay(stepMs)
1268
+ await execFileP(bin, ["send-keys", "-t", name, "-l", String(label || "")])
1269
+ await _delay(stepMs)
1270
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1271
+ }
1272
+ }
1273
+ answered++
1274
+ await waitChanged(sigBefore) // 質問遷移 / 消失を確認してから次へ (二重回答防止)
1275
+ }
1276
+
1277
+ // 完了待ち: メニューが消える (= 回答が claude に届いた) まで待つ。"Submit answers" が
1278
+ // 残っていれば確定する。消失を確認できたら ok:true。dismissMs 経っても残っている =
1279
+ // 回答が届いていない (真の失敗) ので ok:false を返す。ok の正確性が肝: ok:true なら
1280
+ // 回答後の tool_result を 1-A が検知して cancel でカードを畳む / ok:false なら frontend が
1281
+ // カードを復元して再回答できるようにする。両者が食い違うとカード状態が競合する。
1282
+ const finalDl = Date.now() + dismissMs
1283
+ while (Date.now() < finalDl) {
1284
+ const m = parseAskMenu(await cap())
1285
+ if (!m) return { ok: true, answered }
1286
+ const sa = m.options.find((o) => o.isSubmitAnswers)
1287
+ if (sa) await execFileP(bin, ["send-keys", "-t", name, "-l", String(sa.num)])
1288
+ await _delay(180)
1289
+ }
1290
+ opts.logger?.warn(
1291
+ { session: name, answered },
1292
+ "answerAskUserQuestion: menu still visible after retries",
1293
+ )
1294
+ return { ok: false, error: "menu still visible after answering", answered }
1295
+ }
1296
+
1051
1297
  /**
1052
1298
  * 対話 TUI claude へ中断 (Esc) を tmux send-keys で送る。PTY stream に依存しないため、
1053
1299
  * 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