@cocorograph/hub-agent 0.6.46 → 0.6.48

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.46",
3
+ "version": "0.6.48",
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
@@ -452,6 +452,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
452
452
  jsonlLiveWatchers.start()
453
453
  ctx.jsonlLiveWatchers = jsonlLiveWatchers
454
454
 
455
+ // T04784 会話継続バインディングの冪等性ガード: tmux セッション名 → 最後に --resume で
456
+ // 載せ替えた Claude session_id。TUI ビューが remount のたびに claude.tui.bind を送っても、
457
+ // 同じ id への再 respawn (= claude 再起動) を抑止する。
458
+ ctx.tuiReboundSessions = new Map()
459
+
455
460
  const shutdown = async (signal) => {
456
461
  logger.info({ signal }, "shutting down")
457
462
  await runHookBroadcast(plugins, "onAgentStop", ctx)
@@ -1023,6 +1028,7 @@ async function dispatch(msg, ctx) {
1023
1028
  msg.request_id,
1024
1029
  !!msg.allow,
1025
1030
  msg.deny_message,
1031
+ msg.updated_input,
1026
1032
  )
1027
1033
  : false
1028
1034
  if (handledByTui) return
@@ -1100,18 +1106,28 @@ async function dispatch(msg, ctx) {
1100
1106
  })
1101
1107
  }
1102
1108
  // 3) tmux claude を --resume で載せ替え (targetId があるときだけ)。
1103
- let rebind = { ok: false }
1109
+ // 冪等性: 同じ session を同じ id へ既に載せ替え済みなら respawn しない
1110
+ // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
1111
+ // claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
1112
+ let rebind = { ok: false, skipped: false }
1104
1113
  if (targetId && sessionName) {
1105
- rebind = await rebindClaudeSession(sessionName, targetId, {
1106
- model: ctx.config?.claude_model || "",
1107
- permissionMode: ctx.config?.claude_permission_mode || "",
1108
- logger,
1109
- })
1114
+ if (ctx.tuiReboundSessions.get(sessionName) === targetId) {
1115
+ rebind = { ok: true, skipped: true }
1116
+ } else {
1117
+ rebind = await rebindClaudeSession(sessionName, targetId, {
1118
+ cwd,
1119
+ model: ctx.config?.claude_model || "",
1120
+ permissionMode: ctx.config?.claude_permission_mode || "",
1121
+ logger,
1122
+ })
1123
+ if (rebind.ok) ctx.tuiReboundSessions.set(sessionName, targetId)
1124
+ }
1110
1125
  }
1111
1126
  reply({
1112
1127
  session_id: targetId,
1113
1128
  stopped_sdk: stoppedSdk,
1114
1129
  rebound: !!rebind.ok,
1130
+ skipped: !!rebind.skipped,
1115
1131
  error: rebind.ok ? undefined : rebind.error,
1116
1132
  })
1117
1133
  } catch (err) {
package/src/tmux.mjs CHANGED
@@ -716,45 +716,29 @@ export function buildResumeCmd(sessionId, opts = {}) {
716
716
 
717
717
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
718
718
 
719
- /** pane_current_command が claude でなくなる (= シェルに戻った) のを待つ。 */
720
- async function _waitPaneLeftClaude(name, opts = {}, timeoutMs = 4000) {
721
- const bin = tmuxBin(opts)
722
- const start = Date.now()
723
- while (Date.now() - start < timeoutMs) {
724
- try {
725
- const { stdout } = await execFileP(bin, [
726
- "display-message",
727
- "-p",
728
- "-t",
729
- name,
730
- "#{pane_current_command}",
731
- ])
732
- const cmd = (stdout || "").trim().toLowerCase()
733
- if (cmd && !cmd.includes("claude")) return true
734
- } catch {
735
- /* ignore */
736
- }
737
- await _delay(200)
738
- }
739
- return false
740
- }
741
-
742
719
  /**
743
720
  * tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
744
721
  * (T04784 TUI resume binding)。
745
722
  *
746
- * 手順 (in-pane 再起動。PTY/attach は維持するので browser の隠し端末は繋がったまま):
747
- * 1. Ctrl-C を数回送り、生成中断 claude REPL を抜けてシェルへ戻す
748
- * (claude は空入力 Ctrl-C 2 回で exit する)。
749
- * 2. pane_current_command claude でなくなるのを待つ (最大 timeoutMs)。
750
- * 3. 行頭をクリア (C-u) してから `claude --resume <id> [flags]` を送って起動。
723
+ * 旧実装 (Ctrl-C 連打で claude REPL を抜ける) は不安定だった: `claude --continue || claude`
724
+ * `|| claude` フォールバックや生成中の状態で REPL を抜けきれず、resume コマンドが
725
+ * **動作中の claude の入力欄に文字列として打ち込まれて会話に混入**する事故が出た
726
+ * (2026-06-06 finance 実機 / leftClaude:false でも送ってしまっていた)。
727
+ *
728
+ * 新実装は `tmux respawn-pane -k` でペインのプロセスを**原子的に kill → 既定シェルへ置換**し、
729
+ * クリーンなシェルに `claude --resume <id> [flags]` を送る。文字列混入が起きず決定的。
730
+ * respawn はペイン自体を残すので browser の隠し端末 (tmux attach) は繋がったまま、
731
+ * かつ claude 終了後もシェルが残る (ペイン消滅でセッションが落ちるのを防ぐ)。
732
+ *
733
+ * ⚠️ 冪等性 (remount のたびに再起動しない) は呼び出し側 (main.mjs の
734
+ * tuiReboundSessions マップ) で担保する。本関数は呼ばれたら必ず respawn する。
751
735
  *
752
736
  * ベストエフォート。失敗しても throw せず {ok:false} を返し、上位は newest 表示に degrade する。
753
737
  *
754
738
  * @param {string} name tmux セッション名
755
739
  * @param {string} sessionId 載せ替え先 Claude session_id
756
- * @param {{model?:string,permissionMode?:string,logger?:object,tmuxBin?:string}} [opts]
757
- * @returns {Promise<{ok:boolean, leftClaude?:boolean, cmd?:string, error?:string}>}
740
+ * @param {{cwd?:string,model?:string,permissionMode?:string,logger?:object,tmuxBin?:string}} [opts]
741
+ * @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
758
742
  */
759
743
  export async function rebindClaudeSession(name, sessionId, opts = {}) {
760
744
  let cmd
@@ -764,25 +748,21 @@ export async function rebindClaudeSession(name, sessionId, opts = {}) {
764
748
  return { ok: false, error: err?.message || String(err) }
765
749
  }
766
750
  const bin = tmuxBin(opts)
767
- const send = (...keys) =>
768
- execFileP(bin, ["send-keys", "-t", name, ...keys]).catch(() => {})
769
751
  try {
770
- // 1) 生成中断 + REPL 終了 (Ctrl-C 連打)
771
- for (let i = 0; i < 4; i++) {
772
- await send("C-c")
773
- await _delay(180)
774
- }
775
- // 2) シェルに戻るのを待つ。
776
- const leftClaude = await _waitPaneLeftClaude(name, opts, 4000)
777
- // 3) 行をクリアして resume コマンドを起動。
778
- await send("C-u")
779
- await _delay(80)
752
+ // 1) ペインを既定シェルへ原子置換 (-k で現 claude を強制 kill)。コマンド未指定なので
753
+ // クリーンなシェルが起動する。-c でセッションの cwd を維持。
754
+ const respArgs = ["respawn-pane", "-k", "-t", name]
755
+ if (opts.cwd) respArgs.push("-c", opts.cwd)
756
+ await execFileP(bin, respArgs)
757
+ // 2) シェル準備待ち (rc 読み込み等)。send-keys は pty にバッファされるので過敏でなくてよい。
758
+ await _delay(300)
759
+ // 3) クリーンなシェルに resume コマンドを送って claude を起動。
780
760
  await execFileP(bin, ["send-keys", "-t", name, cmd, "Enter"])
781
761
  opts.logger?.info(
782
- { session: name, resume: sessionId, leftClaude },
783
- "tui rebind: relaunched claude with --resume",
762
+ { session: name, resume: sessionId },
763
+ "tui rebind: respawned pane shell + claude --resume",
784
764
  )
785
- return { ok: true, leftClaude, cmd }
765
+ return { ok: true, cmd }
786
766
  } catch (err) {
787
767
  opts.logger?.warn(
788
768
  { session: name, resume: sessionId, err: err?.message },
@@ -114,9 +114,12 @@ export class TuiPermissionBridge extends EventEmitter {
114
114
  * @param {string} request_id
115
115
  * @param {boolean} allow
116
116
  * @param {string} [denyMessage]
117
+ * @param {unknown} [updatedInput] AskUserQuestion 等の回答 ({questions, answers})。
118
+ * フックが PreToolUse の hookSpecificOutput.updatedInput として注入し、claude が
119
+ * 対話 UI を出さず確定する。Bash 等の allow/deny だけで済むツールでは undefined。
117
120
  * @returns {Promise<boolean>} この bridge の管理下だったか
118
121
  */
119
- async resolve(request_id, allow, denyMessage) {
122
+ async resolve(request_id, allow, denyMessage, updatedInput) {
120
123
  if (!this._pending.has(request_id)) return false
121
124
  this._pending.delete(request_id)
122
125
  const decision = allow ? "allow" : "deny"
@@ -125,7 +128,13 @@ export class TuiPermissionBridge extends EventEmitter {
125
128
  try {
126
129
  await writeFile(
127
130
  tmp,
128
- JSON.stringify({ decision, deny_message: denyMessage ?? null }),
131
+ JSON.stringify({
132
+ decision,
133
+ deny_message: denyMessage ?? null,
134
+ // 回答 (AskUserQuestion の {questions, answers} 等)。フックが
135
+ // updatedInput として注入する。無ければ null (Bash 等)。
136
+ updated_input: updatedInput ?? null,
137
+ }),
129
138
  )
130
139
  await rename(tmp, fp)
131
140
  } catch (err) {