@cocorograph/hub-agent 0.6.45 → 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.45",
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",
@@ -1136,6 +1136,51 @@ export class ClaudeStreamBridge extends EventEmitter {
1136
1136
  }
1137
1137
  }
1138
1138
 
1139
+ /**
1140
+ * 指定 cwd / session_id に紐づく SDK セッションを**即停止**する (T04784 TUI bind 用)。
1141
+ *
1142
+ * TUI モードに切り替えた session は対話 TUI (tmux/PTY) の claude が write を担うため、
1143
+ * SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
1144
+ * (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
1145
+ *
1146
+ * @param {{cwd?: string, session_id?: string}} sel
1147
+ * @returns {number} 停止したセッション数
1148
+ */
1149
+ stopSessionsFor({ cwd, session_id } = {}) {
1150
+ const victims = new Set()
1151
+ if (session_id) {
1152
+ const s = this._liveBySession.get(session_id)
1153
+ if (s) victims.add(s)
1154
+ }
1155
+ if (cwd) {
1156
+ for (const s of this._liveBySession.values()) {
1157
+ if (s.cwd === cwd) victims.add(s)
1158
+ }
1159
+ // session_id 未確定 (起動直後) の端末は sessions Map 側で cwd 一致を拾う。
1160
+ for (const s of this.sessions.values()) {
1161
+ if (s.cwd === cwd) victims.add(s)
1162
+ }
1163
+ }
1164
+ let stopped = 0
1165
+ for (const s of victims) {
1166
+ if (s._closed) continue
1167
+ this._dropSessionMappings(s)
1168
+ try {
1169
+ s.close()
1170
+ } catch {
1171
+ /* ignore */
1172
+ }
1173
+ this.emit("exit", {
1174
+ stream_id: s.stream_id,
1175
+ code: 0,
1176
+ reason: "tui-bind-stop",
1177
+ session_id: s.sessionId,
1178
+ })
1179
+ stopped++
1180
+ }
1181
+ return stopped
1182
+ }
1183
+
1139
1184
  /**
1140
1185
  * 新しい Claude セッションを開始する。
1141
1186
  *
package/src/main.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  listSessions as listTmuxSessions,
48
48
  listWorktreeNameHistory,
49
49
  listWorktreeStubs,
50
+ rebindClaudeSession,
50
51
  removeWorktree as removeWorktreeDir,
51
52
  } from "./tmux.mjs"
52
53
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
@@ -451,6 +452,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
451
452
  jsonlLiveWatchers.start()
452
453
  ctx.jsonlLiveWatchers = jsonlLiveWatchers
453
454
 
455
+ // T04784 会話継続バインディングの冪等性ガード: tmux セッション名 → 最後に --resume で
456
+ // 載せ替えた Claude session_id。TUI ビューが remount のたびに claude.tui.bind を送っても、
457
+ // 同じ id への再 respawn (= claude 再起動) を抑止する。
458
+ ctx.tuiReboundSessions = new Map()
459
+
454
460
  const shutdown = async (signal) => {
455
461
  logger.info({ signal }, "shutting down")
456
462
  await runHookBroadcast(plugins, "onAgentStop", ctx)
@@ -1058,6 +1064,80 @@ async function dispatch(msg, ctx) {
1058
1064
  // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
1059
1065
  ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
1060
1066
  return
1067
+ case "claude.tui.bind": {
1068
+ // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
1069
+ // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
1070
+ // 2. その session の SDK を完全停止 (二重ホスト/枠流出を防ぐ)。
1071
+ // 3. 裏の tmux claude を `--resume <id>` で同じ会話に載せ替える (read=write 一致)。
1072
+ // ベストエフォート。失敗時は frontend が従来 newest 表示に degrade する。
1073
+ const request_id = msg.request_id
1074
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1075
+ const sessionName =
1076
+ typeof msg.session_name === "string" ? msg.session_name : ""
1077
+ const reply = (payload) =>
1078
+ ctx.client.send({
1079
+ type: "claude.tui.bind.result",
1080
+ request_id,
1081
+ ...payload,
1082
+ })
1083
+ try {
1084
+ // 1) 載せ替え先 session_id を確定。
1085
+ let targetId =
1086
+ typeof msg.session_id === "string" && msg.session_id
1087
+ ? msg.session_id
1088
+ : null
1089
+ if (!targetId && cwd) {
1090
+ const projectsRoot = await getActiveProjectsRoot()
1091
+ const { sessions } = await listSessions({
1092
+ cwd,
1093
+ projectsRoot,
1094
+ limit: 1,
1095
+ logger,
1096
+ })
1097
+ targetId = sessions?.[0]?.session_id || null
1098
+ }
1099
+ // 2) SDK を停止 (cwd 全体 + 対象 id)。
1100
+ let stoppedSdk = 0
1101
+ if (ctx.claudeBridge) {
1102
+ stoppedSdk = ctx.claudeBridge.stopSessionsFor({
1103
+ cwd,
1104
+ session_id: targetId || undefined,
1105
+ })
1106
+ }
1107
+ // 3) tmux claude を --resume で載せ替え (targetId があるときだけ)。
1108
+ // 冪等性: 同じ session を同じ id へ既に載せ替え済みなら respawn しない
1109
+ // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
1110
+ // claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
1111
+ let rebind = { ok: false, skipped: false }
1112
+ if (targetId && sessionName) {
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
+ }
1124
+ }
1125
+ reply({
1126
+ session_id: targetId,
1127
+ stopped_sdk: stoppedSdk,
1128
+ rebound: !!rebind.ok,
1129
+ skipped: !!rebind.skipped,
1130
+ error: rebind.ok ? undefined : rebind.error,
1131
+ })
1132
+ } catch (err) {
1133
+ logger?.warn(
1134
+ { err: err?.message, sessionName, cwd },
1135
+ "claude.tui.bind failed",
1136
+ )
1137
+ reply({ session_id: null, error: err?.message || String(err) })
1138
+ }
1139
+ return
1140
+ }
1061
1141
  case "claude.mcp.status":
1062
1142
  case "claude.mcp.reconnect":
1063
1143
  case "claude.mcp.authenticate":
package/src/tmux.mjs CHANGED
@@ -679,3 +679,95 @@ export async function createSession(name, cwd, opts = {}) {
679
679
  await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
680
680
  }
681
681
  }
682
+
683
+ /** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
684
+ * Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
685
+ export function isSafeSessionId(sessionId) {
686
+ return typeof sessionId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(sessionId)
687
+ }
688
+
689
+ /** agent 設定 (model / permissionMode) を claude のフラグ文字列に変換する ("" or "--model X ...")。 */
690
+ export function composeClaudeFlags(opts = {}) {
691
+ const model = (opts.model || "").trim()
692
+ const mode = (opts.permissionMode || "").trim()
693
+ const flags = []
694
+ if (model) flags.push(`--model ${model}`)
695
+ if (mode) flags.push(`--permission-mode ${mode}`)
696
+ return flags.join(" ")
697
+ }
698
+
699
+ /**
700
+ * TUI 載せ替え用の `claude --resume <id> [flags]` コマンド文字列を組み立てる (純粋関数)。
701
+ * session_id を明示 resume することで、表示(read)と書込(write)の jsonl を一致させる
702
+ * (`--continue` は解決後 id 不明で read/write が乖離しうるため不可)。
703
+ *
704
+ * @param {string} sessionId
705
+ * @param {{model?:string,permissionMode?:string}} [opts]
706
+ * @returns {string}
707
+ * @throws session_id が安全形でなければ throw (コマンド注入防止)
708
+ */
709
+ export function buildResumeCmd(sessionId, opts = {}) {
710
+ if (!isSafeSessionId(sessionId)) {
711
+ throw new Error(`unsafe session_id for resume: ${String(sessionId)}`)
712
+ }
713
+ const flags = composeClaudeFlags(opts)
714
+ return `claude --resume ${sessionId}${flags ? " " + flags : ""}`.trim()
715
+ }
716
+
717
+ const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
718
+
719
+ /**
720
+ * tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
721
+ * (T04784 TUI resume binding)。
722
+ *
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 する。
735
+ *
736
+ * ベストエフォート。失敗しても throw せず {ok:false} を返し、上位は newest 表示に degrade する。
737
+ *
738
+ * @param {string} name tmux セッション名
739
+ * @param {string} sessionId 載せ替え先 Claude session_id
740
+ * @param {{cwd?:string,model?:string,permissionMode?:string,logger?:object,tmuxBin?:string}} [opts]
741
+ * @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
742
+ */
743
+ export async function rebindClaudeSession(name, sessionId, opts = {}) {
744
+ let cmd
745
+ try {
746
+ cmd = buildResumeCmd(sessionId, opts) // session_id 検証もここで実施
747
+ } catch (err) {
748
+ return { ok: false, error: err?.message || String(err) }
749
+ }
750
+ const bin = tmuxBin(opts)
751
+ try {
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 を起動。
760
+ await execFileP(bin, ["send-keys", "-t", name, cmd, "Enter"])
761
+ opts.logger?.info(
762
+ { session: name, resume: sessionId },
763
+ "tui rebind: respawned pane shell + claude --resume",
764
+ )
765
+ return { ok: true, cmd }
766
+ } catch (err) {
767
+ opts.logger?.warn(
768
+ { session: name, resume: sessionId, err: err?.message },
769
+ "tui rebind failed",
770
+ )
771
+ return { ok: false, error: err?.message || String(err) }
772
+ }
773
+ }