@cocorograph/hub-agent 0.6.63 → 0.6.65

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.6.63",
3
+ "version": "0.6.65",
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
@@ -573,7 +573,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
573
573
  const tuiPermissionBridge = new TuiPermissionBridge({ logger })
574
574
  tuiPermissionBridge.on(
575
575
  "permission",
576
- ({ request_id, session_id, cwd, tool_name, input }) => {
576
+ ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
577
577
  if (cwd) {
578
578
  try {
579
579
  recordChatActivity(cwd, { inputPending: true })
@@ -589,6 +589,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
589
589
  request_id,
590
590
  tool_name,
591
591
  input,
592
+ // 質問/承認カードの直前アシスタント説明 (フック由来。browser がカード上部に表示)。
593
+ context_text: context_text ?? null,
592
594
  })
593
595
  },
594
596
  )
@@ -1398,15 +1400,19 @@ async function dispatch(msg, ctx) {
1398
1400
  // model="" は「デフォルト」= `/model default`。frontend へはそのまま空で返し、
1399
1401
  // 解決後の実 id は次ターンの jsonl 由来 (message.model) に委ねる。
1400
1402
  const model = typeof msg.model === "string" ? msg.model : ""
1403
+ // 先に broadcast してフロントの fallback (raw `/model` paste) を即キャンセルさせる。
1404
+ // setTuiModel は「Switch model?」確認ダイアログの描画検知で数秒かかり得るため、その
1405
+ // 完了を待つとフロントの MODEL_SYNC_FALLBACK_MS が先に発火して二重 paste になる。
1406
+ // バッジは要求モデルを即表示し、確定値は次ターンの jsonl (message.model) で補正される。
1407
+ ctx.client.send({
1408
+ type: "claude.tui.model",
1409
+ cwd: cwd || undefined,
1410
+ session_name: sessionName,
1411
+ model,
1412
+ })
1401
1413
  ;(async () => {
1402
1414
  try {
1403
1415
  await setTuiModel(sessionName, model || "default", { logger })
1404
- ctx.client.send({
1405
- type: "claude.tui.model",
1406
- cwd: cwd || undefined,
1407
- session_name: sessionName,
1408
- model,
1409
- })
1410
1416
  logger.info(
1411
1417
  { session: sessionName, cwd, model: model || "(default)" },
1412
1418
  "tui model switched → notified browser",
@@ -1431,15 +1437,19 @@ async function dispatch(msg, ctx) {
1431
1437
  if (!sessionName) return
1432
1438
  // effort="" は「auto」= `/effort auto`。frontend へはそのまま空で返し、バッジは auto 表示。
1433
1439
  const effort = typeof msg.effort === "string" ? msg.effort : ""
1440
+ // setModel と同様、先に broadcast してフロントの fallback (raw `/effort` paste) を即
1441
+ // キャンセルさせる。setTuiEffort も「Change effort level?」確認ダイアログの描画検知で
1442
+ // 数秒かかり得るため、完了待ちだと EFFORT_SYNC_FALLBACK_MS が先に発火して二重 paste
1443
+ // になる。effort は jsonl に記録されないので、この即時 broadcast が表示の根拠になる。
1444
+ ctx.client.send({
1445
+ type: "claude.tui.effort",
1446
+ cwd: cwd || undefined,
1447
+ session_name: sessionName,
1448
+ effort,
1449
+ })
1434
1450
  ;(async () => {
1435
1451
  try {
1436
1452
  await setTuiEffort(sessionName, effort || "auto", { logger })
1437
- ctx.client.send({
1438
- type: "claude.tui.effort",
1439
- cwd: cwd || undefined,
1440
- session_name: sessionName,
1441
- effort,
1442
- })
1443
1453
  logger.info(
1444
1454
  { session: sessionName, cwd, effort: effort || "(auto)" },
1445
1455
  "tui effort switched → notified browser",
@@ -1535,6 +1545,8 @@ async function dispatch(msg, ctx) {
1535
1545
  request_id: p.request_id,
1536
1546
  tool_name: p.tool_name,
1537
1547
  input: p.input,
1548
+ // 再 hydrate でも直前説明を保つ (listPending が payload ごと保持している)。
1549
+ context_text: p.context_text ?? null,
1538
1550
  })
1539
1551
  }
1540
1552
  if (pend.length) {
package/src/tmux.mjs CHANGED
@@ -785,6 +785,74 @@ export function buildFreshCmd(opts = {}) {
785
785
 
786
786
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
787
787
 
788
+ /**
789
+ * `/model` / `/effort` 切替時に claude TUI が出す確認ダイアログを検知する正規表現。
790
+ *
791
+ * claude v2.1.170 実機確認 (2026-06-10): 会話に出力済みの履歴 (prior output) があると、
792
+ * モデル/effort 切替は即時反映されず確認ダイアログを挟む。ダイアログは:
793
+ * - タイトル: "Switch model?" / "Change effort level?"
794
+ * - 本文: "This conversation is cached for the current model. Switching to … means the
795
+ * full history gets re-read on your next message."
796
+ * - 選択肢: "Yes, switch to …" (デフォルトハイライト) / "No, go back"
797
+ * デフォルトが確定側なので Enter 1 回で承認できる。複数マーカーのいずれかで判定する
798
+ * (文言の小変更に多少強くする)。
799
+ */
800
+ const SWITCH_CONFIRM_DIALOG_RE =
801
+ /Switch model\?|Change effort level\?|Yes, switch to|No, go back|full history gets re-read/i
802
+
803
+ /**
804
+ * `/model` / `/effort` の確認ダイアログを「描画を検知してから」Enter で畳む。
805
+ *
806
+ * 旧実装は本文確定 Enter の一定時間後に盲打ちで追い Enter を 1 回送っていたが、確認
807
+ * ダイアログのレンダはコマンド確定から遅延することがあり (TUI の全画面再描画ラグ)、
808
+ * 固定待ちの Enter がダイアログ描画前に着弾して取りこぼされ、ダイアログが残ったまま
809
+ * 次の入力の Enter を吸収する事故になっていた。代わりにペインを短周期で読み、ダイアログ
810
+ * が実際に描画されたのを確認してから Enter を送り、消えるまで数回リトライする。ダイアログ
811
+ * が出ない場合 (prior output 無し = 即時反映) は余計な Enter を送らない (空 Enter が
812
+ * 別の場面で誤確定するのを避ける)。ベストエフォート。
813
+ *
814
+ * タイミングは opts で上書きできる (テストで短縮するため):
815
+ * - confirmAppearMs: ダイアログ出現を待つ上限 (既定 2400ms)
816
+ * - confirmPollMs: 出現待ちのポーリング間隔 (既定 150ms)
817
+ * - confirmDismissMs: 承認後にダイアログ消失を待つ上限 (既定 2000ms)
818
+ *
819
+ * @param {string} bin tmux バイナリパス
820
+ * @param {string} name tmux セッション名
821
+ * @param {{logger?:object,tmuxBin?:string,confirmAppearMs?:number,confirmPollMs?:number,confirmDismissMs?:number}} opts
822
+ * @returns {Promise<boolean>} 確認ダイアログを承認したら true
823
+ */
824
+ async function _confirmSwitchDialog(bin, name, opts) {
825
+ const appearMs = opts.confirmAppearMs ?? 2400
826
+ const pollMs = opts.confirmPollMs ?? 150
827
+ const dismissMs = opts.confirmDismissMs ?? 2000
828
+ // ダイアログ出現待ち: コマンド確定から最大 appearMs 観測する (レンダ遅延の上限想定)。
829
+ const APPEAR_DEADLINE = Date.now() + appearMs
830
+ let appeared = false
831
+ while (Date.now() < APPEAR_DEADLINE) {
832
+ const text = await capturePane(name, { ...opts, noCache: true })
833
+ if (SWITCH_CONFIRM_DIALOG_RE.test(text)) {
834
+ appeared = true
835
+ break
836
+ }
837
+ await _delay(pollMs)
838
+ }
839
+ if (!appeared) return false // prior output 無し = 即時反映済み。追い Enter 不要。
840
+
841
+ // ダイアログが消える (= 承認反映) まで Enter を送ってリトライする。
842
+ const DISMISS_DEADLINE = Date.now() + dismissMs
843
+ while (Date.now() < DISMISS_DEADLINE) {
844
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
845
+ await _delay(180)
846
+ const after = await capturePane(name, { ...opts, noCache: true })
847
+ if (!SWITCH_CONFIRM_DIALOG_RE.test(after)) return true
848
+ }
849
+ opts.logger?.warn(
850
+ { session: name },
851
+ "switch confirm dialog still visible after retries",
852
+ )
853
+ return true
854
+ }
855
+
788
856
  /**
789
857
  * 遅延回答 resume: 承認/質問カードへの回答がフックの生存中に間に合わなかったとき、
790
858
  * 回答を「新しいユーザーメッセージ」として同一 tmux セッションの対話 claude へ届ける。
@@ -921,9 +989,9 @@ export async function setTuiModel(name, modelArg, opts = {}) {
921
989
  await _delay(120)
922
990
  // Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
923
991
  await execFileP(bin, ["send-keys", "-t", name, "Enter"])
924
- // prior output ありの場合に出るモデル切替の確認プロンプトを Enter で畳む。
925
- await _delay(450)
926
- await execFileP(bin, ["send-keys", "-t", name, "Enter"])
992
+ // prior output ありの場合に出る「Switch model?」確認ダイアログを、描画を検知してから
993
+ // Enter で畳む (固定待ちの盲打ち Enter はレンダ遅延と競合して取りこぼすため)
994
+ await _confirmSwitchDialog(bin, name, opts)
927
995
  return { ok: true }
928
996
  } catch (err) {
929
997
  opts.logger?.warn(
@@ -941,9 +1009,11 @@ export async function setTuiModel(name, modelArg, opts = {}) {
941
1009
  * 同設計で、frontend は raw pty.data ではなく claude.tui.setEffort を送り、agent 側で本関数を
942
1010
  * 実行 → 全ブラウザへ claude.tui.effort を broadcast して全端末を実 effort に揃える。
943
1011
  *
944
- * `/effort` は引数 (low/medium/high/xhigh/max/auto) を受理し即時反映する (確認プロンプトを
945
- * 挟まない)。effort 非対応レベルを与えても CLI がモデルの対応上限へ自動フォールバックする。
946
- * `auto` はモデル既定へリセット。copy-mode に入っているとキーが奪われるので先に抜ける。
1012
+ * `/effort` は引数 (low/medium/high/xhigh/max/auto) を受理する。effort 非対応レベルを
1013
+ * 与えても CLI がモデルの対応上限へ自動フォールバックする。`auto` はモデル既定へリセット。
1014
+ * claude v2.1.170 以降は会話に出力済みの履歴があると「Change effort level?」確認ダイアログ
1015
+ * を挟むようになった (モデル切替と同じ S1_ コンポーネント) ため、setTuiModel と同様に描画を
1016
+ * 検知してから Enter で畳む。copy-mode に入っているとキーが奪われるので先に抜ける。
947
1017
  * ベストエフォート。
948
1018
  *
949
1019
  * @param {string} name tmux セッション名
@@ -977,6 +1047,9 @@ export async function setTuiEffort(name, effortArg, opts = {}) {
977
1047
  await _delay(120)
978
1048
  // Enter で本文確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
979
1049
  await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1050
+ // prior output ありの場合に出る「Change effort level?」確認ダイアログを、描画を検知して
1051
+ // から Enter で畳む (setTuiModel と同じ堅牢化)。
1052
+ await _confirmSwitchDialog(bin, name, opts)
980
1053
  return { ok: true }
981
1054
  } catch (err) {
982
1055
  opts.logger?.warn(
@@ -111,6 +111,9 @@ export class TuiPermissionBridge extends EventEmitter {
111
111
  cwd: body.cwd ?? null,
112
112
  tool_name: body.tool_name ?? "",
113
113
  input: body.tool_input ?? {},
114
+ // 質問/承認カードの直前アシスタント説明 (フックが transcript から抽出して同梱)。
115
+ // browser はこれをカード上部に表示し、jsonl tail の到着レースに依らず文脈を読める。
116
+ context_text: body.context_text ?? null,
114
117
  }
115
118
  // payload も保持する (セッション切替でビュー再マウント時の re-hydrate / listPending 用)。
116
119
  this._pending.set(request_id, { payload, at: Date.now() })