@cocorograph/hub-agent 0.6.49 → 0.6.50

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.49",
3
+ "version": "0.6.50",
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
@@ -31,7 +31,11 @@ import {
31
31
  } from "./claude-history.mjs"
32
32
  import { listAgents } from "./agents.mjs"
33
33
  import { listSkills } from "./skills.mjs"
34
- import { listSessionStates } from "./state.mjs"
34
+ import {
35
+ capturePane,
36
+ detectPermissionModeFromText,
37
+ listSessionStates,
38
+ } from "./state.mjs"
35
39
  import {
36
40
  DEFAULT_PROFILE_ID,
37
41
  defaultConfigDir,
@@ -45,6 +49,7 @@ import {
45
49
  buildClaudeCmd,
46
50
  createSession as createTmuxSession,
47
51
  createWorktreeDir,
52
+ cyclePermissionMode,
48
53
  execTmux,
49
54
  killManySessions,
50
55
  killSession as killTmuxSession,
@@ -711,6 +716,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
711
716
  for (const s of states) {
712
717
  let status = s.status
713
718
  let contextPct = s.context_pct
719
+ // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
720
+ // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
721
+ const permissionMode = s.permission_mode ?? null
714
722
  // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
715
723
  // session の cwd に一致する新鮮なチャット信号があれば status/context% を
716
724
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
@@ -738,17 +746,20 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
738
746
  if (
739
747
  !prev ||
740
748
  prev.status !== status ||
741
- prev.context_pct !== contextPct
749
+ prev.context_pct !== contextPct ||
750
+ prev.permission_mode !== permissionMode
742
751
  ) {
743
752
  lastByName.set(s.session_name, {
744
753
  status,
745
754
  context_pct: contextPct,
755
+ permission_mode: permissionMode,
746
756
  })
747
757
  client.send({
748
758
  type: "session.state",
749
759
  session_name: s.session_name,
750
760
  status,
751
761
  context_pct: contextPct,
762
+ permission_mode: permissionMode,
752
763
  })
753
764
  }
754
765
  }
@@ -1127,6 +1138,74 @@ async function dispatch(msg, ctx) {
1127
1138
  // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
1128
1139
  ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
1129
1140
  return
1141
+ case "claude.tui.cyclePermission": {
1142
+ // 権限バッジ押下 → 対話 claude TUI へ shift+tab を送って権限モードを循環。
1143
+ // 旧実装は frontend が raw pty.data (BACKTAB) を送り、各端末が楽観値を
1144
+ // 個別に 1 段進めるだけだったため、複数端末間でズレ、かつ tmux 上で直接
1145
+ // 切り替えた変更とも乖離した。ここで agent が実キー送出 → ペイン再読込で
1146
+ // 実モードを確定 → 全ブラウザへ claude.tui.permission を broadcast し、
1147
+ // 「実際に動いているターミナルの状態」を正本として全端末に同期する。
1148
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1149
+ const sessionName =
1150
+ typeof msg.session_name === "string" ? msg.session_name : ""
1151
+ if (!sessionName) return
1152
+ ;(async () => {
1153
+ try {
1154
+ await cyclePermissionMode(sessionName, { logger })
1155
+ // TUI がフッターバナーを再描画するまで少し待ってから実モードを読む。
1156
+ await new Promise((r) => setTimeout(r, 250))
1157
+ const text = await capturePane(sessionName, { noCache: true })
1158
+ const mode = detectPermissionModeFromText(text)
1159
+ ctx.client.send({
1160
+ type: "claude.tui.permission",
1161
+ cwd: cwd || undefined,
1162
+ session_name: sessionName,
1163
+ permission_mode: mode,
1164
+ })
1165
+ logger.info(
1166
+ { session: sessionName, cwd, mode },
1167
+ "tui permission cycled → notified browser",
1168
+ )
1169
+ } catch (err) {
1170
+ logger.warn(
1171
+ { err: err?.message, session: sessionName },
1172
+ "claude.tui.cyclePermission failed",
1173
+ )
1174
+ }
1175
+ })()
1176
+ return
1177
+ }
1178
+ case "claude.tui.probePermission": {
1179
+ // 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
1180
+ // 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
1181
+ // ブラウザがマウント直後に「今ターミナルが何モードか」を取得し、楽観値や
1182
+ // 古い jsonl 値とのズレを開始時点から揃えるために使う。検出不能 (claude 起動中・
1183
+ // 素のシェル) のときは mode=null となり、frontend 側は据え置く。
1184
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1185
+ const sessionName =
1186
+ typeof msg.session_name === "string" ? msg.session_name : ""
1187
+ if (!sessionName) return
1188
+ ;(async () => {
1189
+ try {
1190
+ const text = await capturePane(sessionName, { noCache: true })
1191
+ const mode = detectPermissionModeFromText(text)
1192
+ // 検出できた時だけ通知する (null で badge を消さない)。
1193
+ if (!mode) return
1194
+ ctx.client.send({
1195
+ type: "claude.tui.permission",
1196
+ cwd: cwd || undefined,
1197
+ session_name: sessionName,
1198
+ permission_mode: mode,
1199
+ })
1200
+ } catch (err) {
1201
+ logger.warn(
1202
+ { err: err?.message, session: sessionName },
1203
+ "claude.tui.probePermission failed",
1204
+ )
1205
+ }
1206
+ })()
1207
+ return
1208
+ }
1130
1209
  case "claude.tui.bind": {
1131
1210
  // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
1132
1211
  // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
package/src/state.mjs CHANGED
@@ -82,6 +82,43 @@ export function detectContextPctFromText(text) {
82
82
  return null
83
83
  }
84
84
 
85
+ // 対話 claude TUI のフッターに出る権限モードバナー (claude v2.1 系で実機確認)。
86
+ // shift+tab で循環: default(バナー無し) → accept edits → plan → auto → …
87
+ const PERMISSION_BANNERS = [
88
+ [/⏵⏵\s*accept edits on/i, "acceptEdits"],
89
+ [/⏸\s*plan mode on/i, "plan"],
90
+ [/⏵⏵\s*auto mode on/i, "auto"],
91
+ [/bypass permissions on/i, "bypassPermissions"],
92
+ ]
93
+
94
+ /**
95
+ * 対話 claude TUI のペインテキストから現在の権限モードを判定する。
96
+ *
97
+ * 全端末で「実際に動いているターミナルの権限状態」を同期するための正本。
98
+ * jsonl の permissionMode は送信ターン毎にしか記録されないため、shift+tab した
99
+ * だけ (未送信) の変更や tmux 上で直接切り替えた変更は jsonl に出ない。ペインの
100
+ * フッターバナーは shift+tab 直後に即更新されるので、これを正本にする。
101
+ *
102
+ * - 明示バナー (accept edits / plan / auto / bypass) があればそのモード。
103
+ * - バナーが無い場合、claude TUI のフッターが見えている時だけ "default" と判定する
104
+ * (素のシェルを default と誤検出しないためのガード)。
105
+ * - claude TUI と判別できなければ null (= 不明、呼び出し側は据え置き)。
106
+ *
107
+ * @param {string} text capturePane の結果 (ANSI 除去済み)
108
+ * @returns {string|null}
109
+ */
110
+ export function detectPermissionModeFromText(text) {
111
+ if (!text) return null
112
+ for (const [re, mode] of PERMISSION_BANNERS) {
113
+ if (re.test(text)) return mode
114
+ }
115
+ // バナー無し = default。ただし claude TUI のフッターが見えている時だけ assert する。
116
+ if (/← for agents|for shortcuts|shift\+tab to cycle/i.test(text)) {
117
+ return "default"
118
+ }
119
+ return null
120
+ }
121
+
85
122
  export async function listSessionNames(opts = {}) {
86
123
  const tmuxBin = opts.tmuxBin || "tmux"
87
124
  try {
@@ -165,6 +202,7 @@ export async function detectSessionState(sessionName, opts = {}) {
165
202
  const text = await capturePane(sessionName, opts)
166
203
  const defaultStatus = detectStatusFromText(text)
167
204
  const defaultContextPct = detectContextPctFromText(text)
205
+ const defaultPermissionMode = detectPermissionModeFromText(text)
168
206
 
169
207
  if (opts.plugins && opts.plugins.length) {
170
208
  const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
@@ -177,11 +215,17 @@ export async function detectSessionState(sessionName, opts = {}) {
177
215
  return {
178
216
  status: hookResult.result.status || defaultStatus,
179
217
  context_pct: hookResult.result.context_pct ?? defaultContextPct,
218
+ permission_mode:
219
+ hookResult.result.permission_mode ?? defaultPermissionMode,
180
220
  }
181
221
  }
182
222
  }
183
223
 
184
- return { status: defaultStatus, context_pct: defaultContextPct }
224
+ return {
225
+ status: defaultStatus,
226
+ context_pct: defaultContextPct,
227
+ permission_mode: defaultPermissionMode,
228
+ }
185
229
  }
186
230
 
187
231
  /** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
package/src/tmux.mjs CHANGED
@@ -716,6 +716,53 @@ export function buildResumeCmd(sessionId, opts = {}) {
716
716
 
717
717
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
718
718
 
719
+ /**
720
+ * 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
721
+ *
722
+ * 全端末で「実際に動いているターミナルの権限状態」を同期する仕組みの書込側
723
+ * (読込側は state.mjs の detectPermissionModeFromText)。フロントからの権限バッジ
724
+ * 押下は raw pty.data ではなく `claude.tui.cyclePermission` を送り、agent 側で
725
+ * 本関数を実行 → ペイン再読込で実モードを確定 → 全ブラウザへ broadcast する。
726
+ * これにより楽観値が端末間でズレず、jsonl 未記録 (未送信) の変更も即同期される。
727
+ *
728
+ * copy-mode 等に入っているとキーが奪われるので、入っていれば先に抜ける
729
+ * (フロントの cancelTmuxMode 相当を agent 側でも担保)。ベストエフォート。
730
+ *
731
+ * @param {string} name tmux セッション名
732
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
733
+ * @returns {Promise<{ok:boolean, error?:string}>}
734
+ */
735
+ export async function cyclePermissionMode(name, opts = {}) {
736
+ const bin = tmuxBin(opts)
737
+ try {
738
+ // copy-mode 等に入っていると BTab が奪われるので、入っている時だけ抜ける。
739
+ try {
740
+ const { stdout } = await execFileP(bin, [
741
+ "display-message",
742
+ "-p",
743
+ "-t",
744
+ `${name}:`,
745
+ "-F",
746
+ "#{pane_in_mode}",
747
+ ])
748
+ if (stdout.trim() === "1") {
749
+ await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
750
+ }
751
+ } catch {
752
+ // pane_in_mode 取得失敗はベストエフォートで無視 (そのまま BTab を送る)。
753
+ }
754
+ // BTab = shift+tab。claude TUI が権限モードを 1 段循環する。
755
+ await execFileP(bin, ["send-keys", "-t", name, "BTab"])
756
+ return { ok: true }
757
+ } catch (err) {
758
+ opts.logger?.warn(
759
+ { session: name, err: err?.message },
760
+ "cyclePermissionMode failed",
761
+ )
762
+ return { ok: false, error: err?.message || String(err) }
763
+ }
764
+ }
765
+
719
766
  /**
720
767
  * tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
721
768
  * (T04784 TUI resume binding)。