@cocorograph/hub-agent 0.5.10 → 0.5.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.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 新規 workspace ディレクトリ初期化時の CLAUDE.md 生成。
3
+ *
4
+ * ~/hub/bin/generate-claude-md (Python) と同等の仕様を Node 側に持つ。
5
+ * - `<dirName>/_director` slug で Hub director を引き当て、見つかれば
6
+ * frontmatter (client / domain / industry / service / tech_stack /
7
+ * google_drive) を反映した構造化 CLAUDE.md を書き出す
8
+ * - 認証トークン (`~/.claude/.hub_token.json`) が無い / API 失敗 / director
9
+ * 未登録ならプレースホルダ CLAUDE.md を書き出す
10
+ *
11
+ * CLAUDE.md が既存ならそのまま (上書きしない)。
12
+ */
13
+ import fs from "node:fs/promises"
14
+ import os from "node:os"
15
+ import path from "node:path"
16
+
17
+ const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
18
+ const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
19
+
20
+ async function readJsonOrNull(p) {
21
+ try {
22
+ const text = await fs.readFile(p, "utf-8")
23
+ return JSON.parse(text)
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ async function loadAccessToken() {
30
+ const data = await readJsonOrNull(TOKEN_PATH)
31
+ return (data && typeof data.access === "string" && data.access) || null
32
+ }
33
+
34
+ async function fetchDirector({ hubUrl, accessToken, dirName, fetchImpl }) {
35
+ const f = fetchImpl || globalThis.fetch
36
+ if (!f) return null
37
+ const slug = `${dirName}/_director`
38
+ const url =
39
+ `${hubUrl.replace(/\/+$/, "")}/api/knowledge-pages/` +
40
+ `?slug=${encodeURIComponent(slug)}&doc_type=director`
41
+ try {
42
+ const res = await f(url, {
43
+ headers: { Authorization: `Bearer ${accessToken}` },
44
+ })
45
+ if (!res.ok) return null
46
+ const payload = await res.json()
47
+ const items = payload?.results || []
48
+ return items[0] || null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ function renderWithDirector(dirName, director) {
55
+ const fm = director?.frontmatter || {}
56
+ const client = (fm.client || "").trim()
57
+ const domain = (fm.domain || "").trim()
58
+ const industry = (fm.industry || "").trim()
59
+ const service = (fm.service || "").trim()
60
+ const techStack = Array.isArray(fm.tech_stack) ? fm.tech_stack : []
61
+ const googleDrive = (fm.google_drive || "").trim()
62
+
63
+ const lines = [
64
+ "# CLAUDE.md",
65
+ "",
66
+ "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.",
67
+ "",
68
+ "## プロジェクト概要",
69
+ "",
70
+ ]
71
+ if (client) lines.push(`- **クライアント**: ${client}`)
72
+ if (domain) lines.push(`- **ドメイン**: ${domain}`)
73
+ if (industry) lines.push(`- **業種**: ${industry}`)
74
+ if (service) lines.push(`- **サービス**: ${service}`)
75
+ if (!(client || domain || industry || service)) {
76
+ lines.push("- (director frontmatter に該当情報なし)")
77
+ }
78
+ lines.push("")
79
+
80
+ if (techStack.length > 0) {
81
+ lines.push("## 技術スタック")
82
+ lines.push("")
83
+ for (const t of techStack) lines.push(`- ${t}`)
84
+ lines.push("")
85
+ }
86
+
87
+ lines.push("## 関連")
88
+ lines.push("")
89
+ lines.push(
90
+ `- **Hub Director**: \`[[${dirName}/_director]]\` — 案件の進行・意思決定・直近ログ`,
91
+ )
92
+ if (googleDrive) {
93
+ lines.push(`- **Google Drive**: \`$GDRIVE/${googleDrive}/\` — 成果物・素材`)
94
+ }
95
+ lines.push("")
96
+
97
+ lines.push("## 初期化")
98
+ lines.push("")
99
+ lines.push(
100
+ "これは hub-agent が director frontmatter から自動生成した雛形です。",
101
+ )
102
+ lines.push(
103
+ "開発手順・SSH 接続情報・横断ナレッジへのポインタなど、より詳細な内容は",
104
+ )
105
+ lines.push(
106
+ "このリポジトリで `claude` を起動して `/init-claude-md` スキルを実行してください。",
107
+ )
108
+ lines.push("")
109
+ return lines.join("\n")
110
+ }
111
+
112
+ function renderPlaceholder(dirName) {
113
+ return [
114
+ "# CLAUDE.md",
115
+ "",
116
+ "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.",
117
+ "",
118
+ "## 関連",
119
+ "",
120
+ `- **Hub Director**: \`[[${dirName}/_director]]\` — 案件の進行・意思決定・直近ログ (未作成)`,
121
+ "",
122
+ "## 初期化",
123
+ "",
124
+ "これは hub-agent の自動生成プレースホルダです。Hub director も未作成です。",
125
+ "詳細を生成するには、このリポジトリで `claude` を起動して `/init-claude-md` スキルを実行してください。",
126
+ "",
127
+ ].join("\n")
128
+ }
129
+
130
+ /**
131
+ * `<targetDir>/CLAUDE.md` を必要に応じて生成する。
132
+ *
133
+ * - CLAUDE.md が既存なら何もしない (上書きしない)
134
+ * - Hub director が見つかれば frontmatter 連動の構造化 CLAUDE.md を生成
135
+ * - 見つからない / 認証なし / API 失敗ならプレースホルダを生成
136
+ *
137
+ * @param {object} args
138
+ * @param {string} args.targetDir 絶対パス
139
+ * @param {string} args.dirName director slug 解決用のディレクトリ名 (例: D00611_at-pocket.com)
140
+ * @param {string} [args.hubUrl] Hub API base (default: https://api.hub.cocorograph.com)
141
+ * @param {string} [args.accessToken] 省略時は `~/.claude/.hub_token.json` から読む
142
+ * @param {object} [args.logger]
143
+ * @param {Function} [args.fetchImpl] テスト注入用
144
+ * @returns {Promise<{ written: boolean, source: 'existing' | 'director' | 'placeholder' }>}
145
+ */
146
+ export async function ensureClaudeMd({
147
+ targetDir,
148
+ dirName,
149
+ hubUrl,
150
+ accessToken,
151
+ logger,
152
+ fetchImpl,
153
+ } = {}) {
154
+ if (!targetDir) throw new Error("ensureClaudeMd requires targetDir")
155
+ if (!dirName) throw new Error("ensureClaudeMd requires dirName")
156
+ const claudeMdPath = path.join(targetDir, "CLAUDE.md")
157
+ try {
158
+ await fs.access(claudeMdPath)
159
+ return { written: false, source: "existing" }
160
+ } catch {
161
+ /* not found → continue */
162
+ }
163
+
164
+ // accessToken は明示 null (テスト用) で「token を読まない」を選べるようにする。
165
+ // undefined のときだけ ~/.claude/.hub_token.json にフォールバックする。
166
+ const token =
167
+ accessToken === undefined ? await loadAccessToken() : accessToken || null
168
+ const url = hubUrl || DEFAULT_HUB_API
169
+ const director = token
170
+ ? await fetchDirector({
171
+ hubUrl: url,
172
+ accessToken: token,
173
+ dirName,
174
+ fetchImpl,
175
+ })
176
+ : null
177
+
178
+ const body = director
179
+ ? renderWithDirector(dirName, director)
180
+ : renderPlaceholder(dirName)
181
+ const source = director ? "director" : "placeholder"
182
+
183
+ await fs.writeFile(claudeMdPath, body, "utf-8")
184
+ logger?.info?.(
185
+ { dirName, claudeMdPath, source },
186
+ "wrote CLAUDE.md for new workspace dir",
187
+ )
188
+ return { written: true, source }
189
+ }
package/src/tmux.mjs CHANGED
@@ -19,6 +19,7 @@ import os from "node:os"
19
19
  import path from "node:path"
20
20
  import { promisify } from "node:util"
21
21
 
22
+ import { ensureClaudeMd } from "./claude-md.mjs"
22
23
  import { detectSessionState } from "./state.mjs"
23
24
  import { getSessionUsages } from "./usage.mjs"
24
25
 
@@ -26,8 +27,12 @@ const execFileP = promisify(execFile)
26
27
 
27
28
  const DEFAULT_TMUX_BIN = "tmux"
28
29
 
29
- const HUB_PROJECTS_BASE =
30
- process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
30
+ // HUB_PROJECTS_BASE は実行時に解決する。テストや起動環境で
31
+ // `process.env.HUB_PROJECTS_BASE` を上書きできるよう、import 時に
32
+ // const として固定しない (tests/tmux.test.mjs が動的に差し替えるため)。
33
+ function hubProjectsBase() {
34
+ return process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
35
+ }
31
36
 
32
37
  function sanitizeTmuxName(s) {
33
38
  return s.replace(/[.:]/g, "-")
@@ -47,6 +52,24 @@ export function expandTilde(p) {
47
52
  return p
48
53
  }
49
54
 
55
+ /**
56
+ * 絶対パス `p` が HUB_PROJECTS_BASE 配下かを判定する。
57
+ *
58
+ * `createSession` の cwd 自動作成判定で使う。base そのもの (`~/hub/projects`)
59
+ * は対象外とし、配下のサブディレクトリのみ true を返す。`path.relative` で
60
+ * `..` を含まないかチェックすることで `/Users/.../hub/projects-backup` のような
61
+ * 接頭辞一致のみの誤検知を排除する。
62
+ */
63
+ export function isUnderHubProjectsBase(p) {
64
+ if (typeof p !== "string" || p.length === 0) return false
65
+ const abs = path.resolve(p)
66
+ const base = path.resolve(hubProjectsBase())
67
+ if (abs === base) return false
68
+ const rel = path.relative(base, abs)
69
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false
70
+ return true
71
+ }
72
+
50
73
  /**
51
74
  * git branch 名から worktree dir 名 (= tmux session 名) を導出する。
52
75
  * 例: "feature/multi-area" -> "feature-multi-area"
@@ -87,8 +110,9 @@ export async function createWorktreeDir(parentDir, branch) {
87
110
  // parentDir に `~/...` 形式が来ても受け付けられるよう先に展開する。
88
111
  // 展開後が絶対パスなら path.resolve は HUB_PROJECTS_BASE を無視して
89
112
  // そのまま使う (Node の path.resolve 仕様)。
90
- const repoDir = path.resolve(HUB_PROJECTS_BASE, expandTilde(parentDir))
91
- if (repoDir !== HUB_PROJECTS_BASE && !repoDir.startsWith(HUB_PROJECTS_BASE + path.sep)) {
113
+ const projectsBase = hubProjectsBase()
114
+ const repoDir = path.resolve(projectsBase, expandTilde(parentDir))
115
+ if (repoDir !== projectsBase && !repoDir.startsWith(projectsBase + path.sep)) {
92
116
  throw new Error("dir outside projects base")
93
117
  }
94
118
  // .git の存在チェック (file or dir)
@@ -127,15 +151,16 @@ export async function createWorktreeDir(parentDir, branch) {
127
151
  */
128
152
  async function buildWorktreeParentMap() {
129
153
  const out = new Map()
154
+ const projectsBase = hubProjectsBase()
130
155
  let projects
131
156
  try {
132
- projects = await fs.readdir(HUB_PROJECTS_BASE, { withFileTypes: true })
157
+ projects = await fs.readdir(projectsBase, { withFileTypes: true })
133
158
  } catch {
134
159
  return out
135
160
  }
136
161
  for (const p of projects) {
137
162
  if (!p.isDirectory() || p.name.startsWith(".")) continue
138
- const wtBase = path.join(HUB_PROJECTS_BASE, p.name, ".claude", "worktrees")
163
+ const wtBase = path.join(projectsBase, p.name, ".claude", "worktrees")
139
164
  let wts
140
165
  try {
141
166
  wts = await fs.readdir(wtBase, { withFileTypes: true })
@@ -348,13 +373,56 @@ export async function createSession(name, cwd, opts = {}) {
348
373
  // cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
349
374
  // 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
350
375
  // fallback して、SessionStart hook が project を解決できなくなる。
376
+ //
377
+ // 例外: 解決後のパスが HUB_PROJECTS_BASE 配下 (= 案件 workspace ディレクトリ)
378
+ // なら、未 clone のメンバー Mac でも cockpit から起動できるよう mkdir -p で
379
+ // 自動生成する。任意の絶対パス (leader cwd 等) や HUB_PROJECTS_BASE 外は
380
+ // 引き続き `cwd not found` を返し、誤った workspace 起動を防ぐ。
351
381
  try {
352
382
  const st = await fs.stat(resolvedCwd)
353
383
  if (!st.isDirectory()) throw new Error("cwd not a directory")
354
384
  } catch (err) {
355
- if (err?.code === "ENOENT") throw new Error(`cwd not found: ${resolvedCwd}`)
356
- if (err?.message === "cwd not a directory") throw err
357
- throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
385
+ if (err?.code === "ENOENT") {
386
+ if (isUnderHubProjectsBase(resolvedCwd)) {
387
+ try {
388
+ await fs.mkdir(resolvedCwd, { recursive: true })
389
+ opts.logger?.info?.(
390
+ { session: name, cwd: resolvedCwd },
391
+ "auto-created workspace dir before tmux new-session",
392
+ )
393
+ } catch (mkdirErr) {
394
+ throw new Error(
395
+ `cwd mkdir failed: ${mkdirErr?.message || String(mkdirErr)}`,
396
+ )
397
+ }
398
+ } else {
399
+ throw new Error(`cwd not found: ${resolvedCwd}`)
400
+ }
401
+ } else if (err?.message === "cwd not a directory") {
402
+ throw err
403
+ } else {
404
+ throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
405
+ }
406
+ }
407
+ // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成する。
408
+ // Hub director があれば frontmatter 連動の構造化版、無ければ最小プレースホルダ。
409
+ // 既存 CLAUDE.md は触らない。失敗しても session 起動自体は止めない (warn のみ)。
410
+ if (isUnderHubProjectsBase(resolvedCwd)) {
411
+ try {
412
+ await ensureClaudeMd({
413
+ targetDir: resolvedCwd,
414
+ dirName: path.basename(resolvedCwd),
415
+ hubUrl: opts.hubUrl,
416
+ accessToken: opts.hubAccessToken,
417
+ logger: opts.logger,
418
+ fetchImpl: opts.fetchImpl,
419
+ })
420
+ } catch (err) {
421
+ opts.logger?.warn?.(
422
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
423
+ "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
424
+ )
425
+ }
358
426
  }
359
427
  // 既存チェック
360
428
  try {
@@ -366,6 +434,19 @@ export async function createSession(name, cwd, opts = {}) {
366
434
  // has-session が非 0 = セッション無し
367
435
  }
368
436
  await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
437
+ // Cockpit web UI からの touch swipe (= SGR wheel escape) を tmux に拾わせるため、
438
+ // 当該 session で mouse mode を ON にする。tmux 2.1+ で `mouse` option は
439
+ // session-scoped、`-t <session>` 指定で他 session への副作用なし。
440
+ // ユーザーの ~/.tmux.conf を編集せずに済む方式。失敗してもセッション起動自体は
441
+ // 妨げないように warn のみで握り潰す。
442
+ try {
443
+ await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
444
+ } catch (err) {
445
+ opts.logger?.warn?.(
446
+ { session: name, err: err?.message || String(err) },
447
+ "tmux set-option mouse on failed (cockpit touch scroll may not work in this session)",
448
+ )
449
+ }
369
450
  // 起動直後 pane の current_path を確認し、想定 cwd と異なれば WARN ログを残す
370
451
  // (Phase 3: 任意検証。tmux 側のクオート / 権限問題で fallback した場合を検知する)
371
452
  if (opts.logger) {