@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 +1 -1
- package/src/extract-paragraph.mjs +94 -0
- package/src/main.mjs +45 -8
- package/src/tmux.mjs +71 -2
- package/src/tool-result-mask.mjs +38 -0
package/package.json
CHANGED
|
@@ -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
|
-
// 質問/承認カードの直前アシスタント説明 (
|
|
690
|
-
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
3015
|
-
wt_path:
|
|
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
|
-
* -
|
|
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
|
/**
|
package/src/tool-result-mask.mjs
CHANGED
|
@@ -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)
|