@cocorograph/hub-agent 0.6.46 → 0.6.47

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.47",
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)
@@ -1100,18 +1105,28 @@ async function dispatch(msg, ctx) {
1100
1105
  })
1101
1106
  }
1102
1107
  // 3) tmux claude を --resume で載せ替え (targetId があるときだけ)。
1103
- let rebind = { ok: false }
1108
+ // 冪等性: 同じ session を同じ id へ既に載せ替え済みなら respawn しない
1109
+ // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
1110
+ // claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
1111
+ let rebind = { ok: false, skipped: false }
1104
1112
  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
- })
1113
+ if (ctx.tuiReboundSessions.get(sessionName) === targetId) {
1114
+ rebind = { ok: true, skipped: true }
1115
+ } else {
1116
+ rebind = await rebindClaudeSession(sessionName, targetId, {
1117
+ cwd,
1118
+ model: ctx.config?.claude_model || "",
1119
+ permissionMode: ctx.config?.claude_permission_mode || "",
1120
+ logger,
1121
+ })
1122
+ if (rebind.ok) ctx.tuiReboundSessions.set(sessionName, targetId)
1123
+ }
1110
1124
  }
1111
1125
  reply({
1112
1126
  session_id: targetId,
1113
1127
  stopped_sdk: stoppedSdk,
1114
1128
  rebound: !!rebind.ok,
1129
+ skipped: !!rebind.skipped,
1115
1130
  error: rebind.ok ? undefined : rebind.error,
1116
1131
  })
1117
1132
  } 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 },