@cocorograph/hub-agent 0.7.10 → 0.7.12

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.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,94 @@
1
+ /**
2
+ * claude TUI (Claude Code 2.1.x) のペイン出力から、
3
+ * 「直近のアシスタント説明テキスト段落」を保守的に抽出する。
4
+ *
5
+ * 目的: PreToolUse フック発火時点で jsonl はまだ前ターン止まりのため、フックの
6
+ * transcript ベース context_text 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
7
+ * 一方、claude TUI のペインには「いま回答中の assistant 本文」が既に描画されている
8
+ * ため、tmux capture-pane の結果からこれを抜き出して permission.request に同梱する
9
+ * のが回答前に説明文をユーザーへ届ける唯一の経路。
10
+ *
11
+ * 出力フォーマット観察 (2026-06-24 実機 claude 2.1.x):
12
+ * ⏺ <本文テキスト> ← アシスタント説明 (これを抽出したい)
13
+ * ⏺ Bash(<cmd>) ← ツール呼び出し (除外したい)
14
+ * ⏺ Read(...) ← ツール呼び出し
15
+ * ⎿ <結果> ← ツール結果
16
+ *
17
+ * 抽出戦略: ペイン末尾から後ろ向き走査で「⏺ で始まり、続く語がツール名形式
18
+ * (CamelCase + `(`) でない最初の行」を探し、そこから下方向に連続する「説明文の段落」
19
+ * を取る。少しでも自信が無いケースは null 返却して呼び出し側で「説明なし」へ縮退。
20
+ *
21
+ * 安全方針: 抽出失敗 = null (壊さない)。誤抽出より null の方が UX 上安全。
22
+ */
23
+
24
+ const ASSIST_PREFIX = "⏺"
25
+
26
+ const _MAX_CHARS = 2000
27
+
28
+ const _MIN_CHARS = 6
29
+
30
+ const _TOOL_CALL_RE = /^[A-Z][A-Za-z0-9_]*\s*\(/
31
+
32
+ const _BOX_LINE_RE = /^[─-▟]/
33
+
34
+ const _STATUS_LINE_RE = /^[✨✹●⚫◯]\s/
35
+
36
+ const _ELLIPSIS_TAIL_RE = /\n[ \t]*…\s*\+\d+\s+lines[^\n]*$/
37
+
38
+ /**
39
+ * @param {string|undefined|null} paneText capturePane の出力 (ANSI 除去済み)
40
+ * @returns {string|null} 直近のアシスタント説明 (失敗時 null)
41
+ */
42
+ export function extractLastAssistantText(paneText) {
43
+ if (typeof paneText !== "string" || !paneText) return null
44
+ const lines = paneText.replace(/\r/g, "").split("\n")
45
+
46
+ let startIdx = -1
47
+ for (let i = lines.length - 1; i >= 0; i--) {
48
+ const line = lines[i]
49
+ if (!line.startsWith(ASSIST_PREFIX + " ")) continue
50
+ const rest = line.slice(ASSIST_PREFIX.length + 1).trimStart()
51
+ if (_TOOL_CALL_RE.test(rest)) continue
52
+ startIdx = i
53
+ break
54
+ }
55
+ if (startIdx < 0) return null
56
+
57
+ const parts = []
58
+ let consecBlank = 0
59
+ for (let i = startIdx; i < lines.length; i++) {
60
+ const line = lines[i]
61
+ if (i > startIdx) {
62
+ if (line.startsWith(ASSIST_PREFIX + " ")) break
63
+ if (_BOX_LINE_RE.test(line)) break
64
+ if (_STATUS_LINE_RE.test(line.trim())) break
65
+ }
66
+ if (!line.trim()) {
67
+ consecBlank++
68
+ if (consecBlank >= 2) break
69
+ parts.push(line)
70
+ continue
71
+ }
72
+ consecBlank = 0
73
+ parts.push(line)
74
+ }
75
+
76
+ let text = parts.join("\n").trimEnd()
77
+ if (text.startsWith(ASSIST_PREFIX)) {
78
+ text = text.slice(ASSIST_PREFIX.length).trimStart()
79
+ }
80
+ text = text.replace(_ELLIPSIS_TAIL_RE, "").trim()
81
+ if (text.length < _MIN_CHARS) return null
82
+
83
+ if (text.length > _MAX_CHARS) {
84
+ text = "…" + text.slice(-_MAX_CHARS)
85
+ }
86
+ return text
87
+ }
88
+
89
+ export const __test = {
90
+ _MAX_CHARS,
91
+ _MIN_CHARS,
92
+ _TOOL_CALL_RE,
93
+ _BOX_LINE_RE,
94
+ }
package/src/main.mjs CHANGED
@@ -20,6 +20,7 @@ import path from "node:path"
20
20
  import pino from "pino"
21
21
 
22
22
  import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
23
+ import { extractLastAssistantText } from "./extract-paragraph.mjs"
23
24
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
24
25
  import { WsClient } from "./ws-client.mjs"
25
26
  import { PtyBridge } from "./pty-bridge.mjs"
@@ -670,7 +671,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
670
671
  const tuiPermissionBridge = new TuiPermissionBridge({ logger })
671
672
  tuiPermissionBridge.on(
672
673
  "permission",
673
- ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
674
+ async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
674
675
  if (cwd) {
675
676
  try {
676
677
  recordChatActivity(cwd, { inputPending: true })
@@ -678,6 +679,33 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
678
679
  /* ignore */
679
680
  }
680
681
  }
682
+ // ── 直前アシスタント説明の抽出 (T05xxx 根治) ────────────────────────────
683
+ // PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
684
+ // している間 claude は jsonl を commit しない) のため、フック側の transcript
685
+ // 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
686
+ // 代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上に
687
+ // 既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
688
+ // 抜き出して context_text に注入する。フックが既に context_text を
689
+ // 持っている場合 (旧経路) はそれを尊重し、空 / null のときだけ上書きする。
690
+ // 抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
691
+ let liveContext = null
692
+ if (!context_text && cwd) {
693
+ try {
694
+ const sessions = await listTmuxSessions({ logger })
695
+ const match = Array.isArray(sessions)
696
+ ? sessions.find((s) => s && s.cwd === cwd)
697
+ : null
698
+ if (match && match.name) {
699
+ const pane = await capturePane(match.name, { noCache: true })
700
+ liveContext = extractLastAssistantText(pane)
701
+ }
702
+ } catch (err) {
703
+ logger?.debug?.(
704
+ { err: err?.message, cwd },
705
+ "capture-pane based context_text extraction failed; falling back to null",
706
+ )
707
+ }
708
+ }
681
709
  client.send({
682
710
  type: "claude.permission.request",
683
711
  stream_id: null,
@@ -686,8 +714,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
686
714
  request_id,
687
715
  tool_name,
688
716
  input,
689
- // 質問/承認カードの直前アシスタント説明 (フック由来。browser がカード上部に表示)。
690
- context_text: context_text ?? null,
717
+ // 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
718
+ // 優先順位: (1) フックが既に同梱した context_text、(2) hub-agent
719
+ // tmux capture-pane で抽出した liveContext、(3) null。
720
+ context_text: context_text || liveContext || null,
691
721
  })
692
722
  },
693
723
  )
@@ -2992,9 +3022,12 @@ async function dispatch(msg, ctx) {
2992
3022
  return
2993
3023
  }
2994
3024
  case "worktree.remove": {
2995
- // body: { request_id, name }
3025
+ // body: { request_id, name, force? }
2996
3026
  // cockpit (PR 1719) のサイドバー削除ボタンから呼ばれる。
2997
- // git worktree remove --force を実行し、走行中 session があれば事前に kill する。
3027
+ // 2 段階削除:
3028
+ // - force=false (デフォルト): 未コミット/未 push を検出して dirty なら削除せず
3029
+ // `dirty: true, changes, unpushed_commits` を返す。フロントが「破棄して削除」確認を出す。
3030
+ // - force=true: 検査をスキップして `git worktree remove --force` で物理削除。
2998
3031
  const name = (msg.name || "").trim()
2999
3032
  if (!name) {
3000
3033
  ctx.client.send({
@@ -3005,14 +3038,18 @@ async function dispatch(msg, ctx) {
3005
3038
  })
3006
3039
  return
3007
3040
  }
3041
+ const force = msg.force === true
3008
3042
  try {
3009
- const { name: removedName, wt_path: wtPath } = await removeWorktreeDir(name)
3043
+ const result = await removeWorktreeDir(name, { force })
3010
3044
  ctx.client.send({
3011
3045
  type: "worktree.remove.result",
3012
3046
  request_id: msg.request_id,
3013
3047
  ok: true,
3014
- name: removedName,
3015
- wt_path: wtPath,
3048
+ name: result.name,
3049
+ wt_path: result.wt_path,
3050
+ dirty: result.dirty === true,
3051
+ changes: result.changes || [],
3052
+ unpushed_commits: result.unpushed_commits || 0,
3016
3053
  })
3017
3054
  } catch (err) {
3018
3055
  ctx.client.send({
package/src/tmux.mjs CHANGED
@@ -232,13 +232,66 @@ export async function listWorktreeStubs(liveSessionNames) {
232
232
  return stubs
233
233
  }
234
234
 
235
+ /**
236
+ * worktree 内の未コミット変更と未 push コミットを検出する(破棄前確認用)。
237
+ *
238
+ * - `git status --porcelain` で未コミット変更(追跡/未追跡/変更/削除)を列挙
239
+ * - `git rev-list --count @{u}..HEAD` で upstream に未 push のコミット数を取得
240
+ *
241
+ * upstream 未設定の worktree(新規ブランチで未 push)も「unpushed あり扱い」にして
242
+ * 誤って削除されないようにする(rev-list が失敗しても unpushed_commits=1 で警告)。
243
+ *
244
+ * @param {string} wtPath worktree の絶対パス
245
+ * @returns {Promise<{dirty: boolean, changes: string[], unpushed_commits: number}>}
246
+ */
247
+ async function inspectWorktreeDirty(wtPath) {
248
+ /** @type {string[]} */
249
+ let changes = []
250
+ let unpushed = 0
251
+ try {
252
+ const { stdout } = await execFileP("git", ["-C", wtPath, "status", "--porcelain"])
253
+ changes = String(stdout || "")
254
+ .split("\n")
255
+ .map((line) => line.replace(/\r$/, ""))
256
+ .filter((line) => line.length > 0)
257
+ } catch {
258
+ // git status 自体が失敗するケースは滅多に無いが、保守的に「変更あり扱い」にせず空配列で続行
259
+ changes = []
260
+ }
261
+ try {
262
+ const { stdout } = await execFileP("git", [
263
+ "-C",
264
+ wtPath,
265
+ "rev-list",
266
+ "--count",
267
+ "@{u}..HEAD",
268
+ ])
269
+ unpushed = parseInt(String(stdout || "0").trim(), 10) || 0
270
+ } catch {
271
+ // upstream 未設定 (新規ブランチで未 push) は rev-list が失敗する → 警告のため 1 扱い
272
+ unpushed = 1
273
+ }
274
+ return {
275
+ dirty: changes.length > 0 || unpushed > 0,
276
+ changes,
277
+ unpushed_commits: unpushed,
278
+ }
279
+ }
280
+
235
281
  /**
236
282
  * `git worktree remove --force <wtPath>` を実行する。事前に live tmux session が
237
283
  * あれば kill する。cockpit `worktree.remove` ハンドラから呼ばれる。
238
284
  *
239
285
  * - name から path を解決するには buildWorktreeIndex で fs を走査する (path 検証も兼ねる)
240
286
  * - HUB_PROJECTS_BASE 配下に居ない path は拒否 (path traversal 防止)
241
- * - 未コミットの変更等で git remove が失敗した場合はそのエラーを上位に投げる
287
+ * - **未コミット変更 / push コミットがあれば、`opts.force=true` でない限り
288
+ * 削除を中止して構造化応答 `{ ok: true, dirty: true, ... }` を返す**(throw しない、
289
+ * 呼び出し元が「破棄して削除」確認ダイアログを出して再呼び出ししやすい設計)。
290
+ * - 未コミットの変更等で git remove 自体が失敗した場合はそのエラーを上位に投げる
291
+ *
292
+ * @param {string} name
293
+ * @param {{ force?: boolean, tmuxBin?: string }} [opts]
294
+ * @returns {Promise<{ name: string, wt_path: string, dirty?: boolean, changes?: string[], unpushed_commits?: number }>}
242
295
  */
243
296
  export async function removeWorktree(name, opts = {}) {
244
297
  const sanitized = sanitizeTmuxName(String(name || "").trim())
@@ -251,6 +304,22 @@ export async function removeWorktree(name, opts = {}) {
251
304
  if (!resolved.startsWith(projectsBase + path.sep)) {
252
305
  throw new Error("worktree path outside projects base")
253
306
  }
307
+
308
+ // force=false (デフォルト): 未コミット / 未 push を検出して dirty なら削除中止
309
+ if (!opts.force) {
310
+ const inspect = await inspectWorktreeDirty(resolved)
311
+ if (inspect.dirty) {
312
+ // セッションは kill せず(誤操作からの復帰を妨げない)、構造化応答で UI に判断を返す
313
+ return {
314
+ name: sanitized,
315
+ wt_path: resolved,
316
+ dirty: true,
317
+ changes: inspect.changes,
318
+ unpushed_commits: inspect.unpushed_commits,
319
+ }
320
+ }
321
+ }
322
+
254
323
  // 走行中の session があれば kill
255
324
  try {
256
325
  await execFileP(tmuxBin(opts), ["kill-session", "-t", sanitized])
@@ -271,7 +340,7 @@ export async function removeWorktree(name, opts = {}) {
271
340
  ? resolved.slice(0, -wtSegment.length - 1)
272
341
  : path.resolve(resolved, "..", "..", "..")
273
342
  await execFileP("git", ["-C", parentRepo, "worktree", "remove", "--force", resolved])
274
- return { name: sanitized, wt_path: resolved }
343
+ return { name: sanitized, wt_path: resolved, dirty: false }
275
344
  }
276
345
 
277
346
  /**
@@ -38,6 +38,18 @@ export const SECRET_PLACEHOLDER = "[REDACTED]"
38
38
  export const SKILL_PROMPT_PLACEHOLDER =
39
39
  "[get_skill_prompt の本文はマスクされています(必要なら再取得できます)]"
40
40
 
41
+ /**
42
+ * デプロイ接続情報の置換マーカー。
43
+ *
44
+ * hub_helper.deploy_creds や mcp__hub_staff__get_deploy_target が返す情報は、
45
+ * 「秘匿値そのものは返さない」設計(参照パス・has_* フラグのみ)になっているが、
46
+ * host/port/user/deploy_method 等のメタが画面・jsonl に出ると別案件サーバーへの
47
+ * 誤デプロイの原因になる(人間オペレーターの目視ミスを誘発)。
48
+ * 一律にプレースホルダで潰し、AI には「Hub から再取得できる」ことだけ知らせる方針。
49
+ */
50
+ export const DEPLOY_CRED_PLACEHOLDER =
51
+ "[デプロイ接続情報はマスクされています(必要なら hub_helper.py deploy_creds で再取得できます)]"
52
+
41
53
  /**
42
54
  * 既知の秘匿情報パターン (誤検知を抑えるため、形が明確なトークンに限定する)。
43
55
  * 各要素は global フラグ付き RegExp。replace で SECRET_PLACEHOLDER に置換する。
@@ -103,11 +115,33 @@ export function isSkillPromptTool(name) {
103
115
  )
104
116
  }
105
117
 
118
+ /**
119
+ * tool 名がデプロイ接続情報を返す MCP ツールか判定する。素名と MCP 名前空間
120
+ * 付き (`mcp__hub_staff__get_deploy_target` / `mcp__hub_staff__deploy_creds` 等)
121
+ * の両方を拾う。
122
+ * @param {unknown} name
123
+ * @returns {boolean}
124
+ */
125
+ export function isDeployCredentialTool(name) {
126
+ if (typeof name !== "string") return false
127
+ return (
128
+ name === "get_deploy_target" ||
129
+ name === "deploy_creds" ||
130
+ name.endsWith("__get_deploy_target") ||
131
+ name.endsWith("__deploy_creds")
132
+ )
133
+ }
134
+
106
135
  /** tool_result の content (string | block[]) をプレースホルダ 1 本へ潰す。 */
107
136
  function skillPlaceholderContent() {
108
137
  return [{ type: "text", text: SKILL_PROMPT_PLACEHOLDER }]
109
138
  }
110
139
 
140
+ /** tool_result の content をデプロイ接続情報プレースホルダ 1 本へ潰す。 */
141
+ function deployCredPlaceholderContent() {
142
+ return [{ type: "text", text: DEPLOY_CRED_PLACEHOLDER }]
143
+ }
144
+
111
145
  /** content blocks 配列の text を redactSecrets で処理した新配列を返す (非破壊)。 */
112
146
  function redactBlocks(blocks) {
113
147
  if (!Array.isArray(blocks)) return blocks
@@ -166,6 +200,10 @@ export function maskMessageObject(obj, toolNames) {
166
200
  changed = true
167
201
  return { ...b, content: skillPlaceholderContent() }
168
202
  }
203
+ if (isDeployCredentialTool(name)) {
204
+ changed = true
205
+ return { ...b, content: deployCredPlaceholderContent() }
206
+ }
169
207
  // 非スキル tool_result: 中身 (string | block[]) の秘匿情報を伏せる。
170
208
  if (typeof b.content === "string") {
171
209
  const masked = redactSecrets(b.content)