@cocorograph/hub-agent 0.7.11 → 0.7.13

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.11",
3
+ "version": "0.7.13",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -29,6 +29,7 @@ const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
29
29
  const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
30
30
  const META_DIR = ".cockpit-sync"
31
31
  const META_FILE = "claude-md.json"
32
+ const BASE_FILE = "CLAUDE.md.base"
32
33
  const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
33
34
 
34
35
  async function readJsonOrNull(p) {
@@ -160,6 +161,23 @@ async function writeMeta(targetDir, meta) {
160
161
  )
161
162
  }
162
163
 
164
+ /**
165
+ * 同期成功時の本文を `.cockpit-sync/CLAUDE.md.base` に保存する。
166
+ *
167
+ * session-end スキル (対話中の Claude) が CLAUDE.md を Hub と 3-way merge する際の
168
+ * "base" 入力として使う。前回 sync 直後のローカル == Hub の本文を保持しておくことで、
169
+ * 「ローカル変更」と「Hub 変更」の差分を別々に抽出してから意味マージできる。
170
+ *
171
+ * meta(claude-md.json) は hash と updated_at しか持たないため、本文そのものが要る
172
+ * 3-way merge の用途ではこのファイルが必要。
173
+ */
174
+ async function writeBaseBody(targetDir, body) {
175
+ if (body == null) return
176
+ const metaDir = path.join(targetDir, META_DIR)
177
+ await fs.mkdir(metaDir, { recursive: true })
178
+ await fs.writeFile(path.join(metaDir, BASE_FILE), body, "utf-8")
179
+ }
180
+
163
181
  /**
164
182
  * 既存ローカル CLAUDE.md を .cockpit-sync/CLAUDE.md.<prefix>.<ts>.bak に退避する。
165
183
  * 同 prefix の .bak は新しい順に BAK_KEEP 件保持し、古いものから FIFO 削除する。
@@ -289,6 +307,8 @@ export async function syncClaudeMdWithHub({
289
307
  lastSyncedHash: hubHash || sha256(hubBody),
290
308
  lastSyncedAt: nowIso(),
291
309
  })
310
+ // 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
311
+ await writeBaseBody(targetDir, hubBody)
292
312
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
293
313
  return { action: "pull" }
294
314
  }
@@ -315,6 +335,9 @@ export async function syncClaudeMdWithHub({
315
335
  lastSyncedHash: localCurrentHash,
316
336
  lastSyncedAt: nowIso(),
317
337
  })
338
+ // 3-way merge 用 base: push 直後は Hub の正本がローカルと一致するので、
339
+ // 次回の base は今 push した本文 (= localBody)。
340
+ await writeBaseBody(targetDir, localBody || "")
318
341
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
319
342
  return { action: "push" }
320
343
  }
@@ -339,6 +362,8 @@ export async function syncClaudeMdWithHub({
339
362
  lastSyncedHash: localCurrentHash,
340
363
  lastSyncedAt: nowIso(),
341
364
  })
365
+ // 3-way merge 用 base: conflict push 後の Hub 正本は端末側本文 (= localBody)。
366
+ await writeBaseBody(targetDir, localBody || "")
342
367
  logger?.warn?.(
343
368
  { dirName },
344
369
  "syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
@@ -349,13 +374,15 @@ export async function syncClaudeMdWithHub({
349
374
  // メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
350
375
  // named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
351
376
  // 以降の sync で食い違いを起こさない。
352
- export { sha256, readMeta, writeMeta, nowIso }
377
+ export { sha256, readMeta, writeMeta, writeBaseBody, nowIso }
353
378
 
354
379
  // 内部テスト用エクスポート
355
380
  export const _internals = {
356
381
  sha256,
357
382
  readMeta,
358
383
  writeMeta,
384
+ writeBaseBody,
359
385
  backupLocalClaudeMd,
360
386
  BAK_KEEP,
387
+ BASE_FILE,
361
388
  }
package/src/claude-md.mjs CHANGED
@@ -22,6 +22,7 @@ import path from "node:path"
22
22
  import {
23
23
  sha256,
24
24
  writeMeta,
25
+ writeBaseBody,
25
26
  nowIso,
26
27
  } from "./claude-md-sync.mjs"
27
28
 
@@ -379,6 +380,8 @@ export async function ensureClaudeMd({
379
380
  lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
380
381
  lastSyncedAt: nowIso(),
381
382
  })
383
+ // 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
384
+ await writeBaseBody(targetDir, hubBody)
382
385
  logger?.info?.(
383
386
  { dirName, claudeMdPath },
384
387
  "wrote CLAUDE.md from Hub project claude_md (initial sync)",
package/src/main.mjs CHANGED
@@ -3022,9 +3022,12 @@ async function dispatch(msg, ctx) {
3022
3022
  return
3023
3023
  }
3024
3024
  case "worktree.remove": {
3025
- // body: { request_id, name }
3025
+ // body: { request_id, name, force? }
3026
3026
  // cockpit (PR 1719) のサイドバー削除ボタンから呼ばれる。
3027
- // 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` で物理削除。
3028
3031
  const name = (msg.name || "").trim()
3029
3032
  if (!name) {
3030
3033
  ctx.client.send({
@@ -3035,14 +3038,18 @@ async function dispatch(msg, ctx) {
3035
3038
  })
3036
3039
  return
3037
3040
  }
3041
+ const force = msg.force === true
3038
3042
  try {
3039
- const { name: removedName, wt_path: wtPath } = await removeWorktreeDir(name)
3043
+ const result = await removeWorktreeDir(name, { force })
3040
3044
  ctx.client.send({
3041
3045
  type: "worktree.remove.result",
3042
3046
  request_id: msg.request_id,
3043
3047
  ok: true,
3044
- name: removedName,
3045
- 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,
3046
3053
  })
3047
3054
  } catch (err) {
3048
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)