@cocorograph/hub-agent 0.8.76 → 0.8.77

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.8.76",
3
+ "version": "0.8.77",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,26 +19,40 @@ export class AsyncTtlCache {
19
19
 
20
20
  async get(build) {
21
21
  if (this.hasValue && this.now() < this.expiresAt) return this.value
22
+ // 合流者はここで inFlight をそのまま受け取る。⚠️ したがって「世代が変わっていないか」の
23
+ // 確認は inFlight **そのものの中**で済ませておく必要がある(下の _buildOnce)。
22
24
  if (this.inFlight) return this.inFlight
23
25
 
24
- const generation = this.generation
25
- const pending = Promise.resolve().then(build)
26
+ const pending = this._buildOnce(build)
26
27
  this.inFlight = pending
27
28
  try {
28
- const value = await pending
29
- // build 中に mutation / state push が走った場合、旧値を最初の caller にも返さない。
30
- // clear() が inFlight を外しているため、新世代の build を開始するか既存の新 build に
31
- // 合流する。read-only snapshot 専用なので再実行は安全。
32
- if (this.generation !== generation) return this.get(build)
33
- this.value = value
34
- this.hasValue = true
35
- this.expiresAt = this.now() + this.ttlMs
36
- return value
29
+ return await pending
37
30
  } finally {
38
31
  if (this.inFlight === pending) this.inFlight = null
39
32
  }
40
33
  }
41
34
 
35
+ /**
36
+ * build を 1 回走らせ、その間に `clear()` が入っていなければ確定して返す。
37
+ *
38
+ * ⚠️ 世代確認をこの promise の **中**に置くのが要点。`get()` 側の await の後に置くと、
39
+ * 確認を通るのは build を開始した最初の caller だけで、`this.inFlight` をそのまま
40
+ * 受け取った合流者は無効化済みの結果を受け取れてしまう(それが元の実装の穴)。
41
+ *
42
+ * 世代が進んでいたら旧結果を捨てて引き直す。`clear()` は generation を進めると同時に
43
+ * `inFlight` を外すので、ここから `get()` を呼び直しても自分自身へ合流することはない。
44
+ * read-only snapshot 専用なので再実行は安全。
45
+ */
46
+ async _buildOnce(build) {
47
+ const generation = this.generation
48
+ const value = await Promise.resolve().then(build)
49
+ if (this.generation !== generation) return await this.get(build)
50
+ this.value = value
51
+ this.hasValue = true
52
+ this.expiresAt = this.now() + this.ttlMs
53
+ return value
54
+ }
55
+
42
56
  clear() {
43
57
  this.generation += 1
44
58
  this.hasValue = false
@@ -0,0 +1,62 @@
1
+ /**
2
+ * worktree を削除している最中の「新規 attach」を止めるためのガード。
3
+ *
4
+ * ⚠️ 削除前に会話を止めるだけでは足りない。停止してから dir を消すまでの間に attach が来ると、
5
+ * **消える直前の dir を cwd とする会話**が新しく生まれ、削除後には cwd が存在しない会話として
6
+ * 残る(「送ったら起きる」方式にしたので、この窓は利用者の送信 1 回で簡単に開く)。
7
+ *
8
+ * ⚠️ 検査は **attach の定義側 2 箇所**(claude-stream-bridge / codex-stream-bridge)に置く。
9
+ * 呼び出し元は現時点で 4 箇所あるが、経路ごとに掛けると経路が増えたときに片方だけ守られる。
10
+ * 会話が生まれる場所は attach だけなので、そこで 1 回見れば足りる。
11
+ *
12
+ * ⚠️ 依存を持たないこと。両 bridge から import されるため、bridge や live-sessions を
13
+ * 参照すると循環する。ここが持つのは「削除中のパス」という事実だけで、
14
+ * 「どの会話が生きているか」は live-sessions.mjs 側の責務。
15
+ */
16
+ import path from "node:path"
17
+
18
+ /** 削除中の worktree パス → 進行中の削除の本数。 */
19
+ const removals = new Map()
20
+
21
+ /**
22
+ * 削除の開始を記録し、**解除関数**を返す。
23
+ *
24
+ * ⚠️ 単純な Set ではなく参照カウントにしてある。同じ worktree に対する削除要求が重なったとき、
25
+ * 先に終わった方が解除すると、まだ削除中の側のガードまで外れてしまうため。
26
+ * 返す解除関数は冪等(2 回以上呼ばれても 1 回ぶんしか減らさない)。呼び出し側は finally で
27
+ * 呼ぶので、成功・失敗のどちらの経路からも二重に到達しうる。
28
+ *
29
+ * @param {string} dir 削除対象の worktree パス
30
+ * @returns {() => void} 解除関数
31
+ */
32
+ export function beginRemoval(dir) {
33
+ const base = path.resolve(dir)
34
+ removals.set(base, (removals.get(base) || 0) + 1)
35
+ let released = false
36
+ return () => {
37
+ if (released) return
38
+ released = true
39
+ const remaining = removals.get(base) - 1
40
+ if (remaining === 0) removals.delete(base)
41
+ else removals.set(base, remaining)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * cwd が削除中の worktree そのもの、またはその配下かどうか。
47
+ *
48
+ * ⚠️ 比較は必ずパス境界で行う。素の `startsWith(base)` にすると `<wt>` と `<wt>-backup` を
49
+ * 取り違えて、削除していない worktree への attach まで拒否する(ガードは弾きすぎる方向にも
50
+ * 壊れるので、両方向の検査がテストにある)。
51
+ *
52
+ * @param {unknown} cwd
53
+ * @returns {boolean}
54
+ */
55
+ export function isUnderRemoval(cwd) {
56
+ if (typeof cwd !== "string" || !cwd) return false
57
+ const resolved = path.resolve(cwd)
58
+ for (const base of removals.keys()) {
59
+ if (resolved === base || resolved.startsWith(base + path.sep)) return true
60
+ }
61
+ return false
62
+ }
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import fs from "node:fs/promises"
3
+ import { expandTilde, prepareChatWorkspace } from "./chat-workspace.mjs"
4
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
5
+
6
+ export const CHAT_START_SYNC_WAIT_MS = 8000
7
+
8
+ const starts = new Map()
9
+
10
+ /** 同じ cwd・CLI の起動を直列化し、既存のライブ会話を再利用する。 */
11
+ export async function ensureChatSession({ cwd, cliKind, bridges, logger, resolveClaudeOptions }) {
12
+ cwd = expandTilde(cwd)
13
+ const key = JSON.stringify([cwd, cliKind])
14
+ const previous = starts.get(key) || Promise.resolve()
15
+ const pending = previous.then(async () => {
16
+ try {
17
+ const existing = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
18
+ if (existing) return { ok: true, session: existing, cwd }
19
+ const bridge = cliKind === "claude" ? bridges?.claudeBridge
20
+ : cliKind === "codex" ? bridges?.codexBridge : null
21
+ if (!bridge?.attach) throw new Error(`chat bridge unavailable: ${cliKind}`)
22
+ const prepared = await prepareChatWorkspace({ cwd, cliKind, waitForSync: false, logger })
23
+ cwd = prepared.cwd
24
+ let timer
25
+ let completed
26
+ try {
27
+ completed = await Promise.race([
28
+ // sync は「指示書が使えるか」に解決する。⚠️ ここを `.then(() => true)` にしない。
29
+ // 同期は書込に失敗しても warn だけで **正常終了**するため、完了=準備成功ではない。
30
+ prepared.sync.then((ready) => ({ ready })),
31
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), CHAT_START_SYNC_WAIT_MS) }),
32
+ ])
33
+ } finally {
34
+ clearTimeout(timer)
35
+ }
36
+ // ⚠️ 待機の打ち切りも、同期の正常終了も、それだけでは起動の許可にならない。指示書が無い
37
+ // cwd で会話を開くと、CLAUDE.md / AGENTS.md が無い状態がその会話の前提として固定され、
38
+ // あとから同期しても開き直すまで直らない。両方まとめて再試行可能な失敗として返す
39
+ // (呼び出し側は「準備中」と「恒久的な不在」を区別できる)。
40
+ if (!completed) {
41
+ logger?.warn?.({ cwd, cliKind }, "chat workspace sync wait timed out; start aborted")
42
+ return { ok: false, error: "workspace_not_ready", retryable: true }
43
+ }
44
+ if (!completed.ready) {
45
+ logger?.warn?.({ cwd, cliKind }, "chat workspace instructions unavailable; start aborted")
46
+ return { ok: false, error: "workspace_not_ready", retryable: true }
47
+ }
48
+ // ⚠️ prepareChatWorkspace が入口で実在を確かめているが (chat-workspace.mjs の
49
+ // `fs.stat(resolvedCwd)`)、そこから上の sync 待ち (最大 CHAT_START_SYNC_WAIT_MS) を挟むので、
50
+ // 待っている間に worktree ごと消えていることがある。**削除中**なら bridge の attach が
51
+ // chat-removal-guard で弾くが、待機中に削除が **完了** するとガードは解除済みなので、
52
+ // そちらでは捕まらない。sync 待ちを抜けた直後に実在を取り直すのが、両方をまとめて塞ぐ
53
+ // 一番小さい形。
54
+ // ⚠️ ガードを削除後も保持し続ける形にはしない。同じパスへ worktree を作り直したときに
55
+ // 起動できなくなる。実在確認なら、消えていれば弾き、作り直されていれば通る。
56
+ // ⚠️ `existsSync` など `node:fs` の同期 API に置き換えないこと。検査が `node:fs/promises`
57
+ // の `stat` をモックしているため、別 API にするとモックを素通りして実ファイルを見に行く。
58
+ // ⚠️ この検査を下の live 再確認より **後ろ**へ動かさないこと (理由は下のコメント)。
59
+ try {
60
+ if (!(await fs.stat(cwd)).isDirectory()) return { ok: false, error: "workspace_missing" }
61
+ } catch {
62
+ return { ok: false, error: "workspace_missing" }
63
+ }
64
+ // ⚠️ 同期の待機中に別経路 (画面からの attach 等) が同じ cwd の会話を開いていることがある。
65
+ // starts の直列化が畳むのは同じ key の起動だけなので、ここで live を取り直さないと
66
+ // 同じ cwd に会話を二重に起こす。
67
+ // ⚠️ **この再確認から attach までの間に await を 1 つも置かない。** 通常 attach (main.mjs の
68
+ // claude.attach / codex.attach) は starts の直列化対象外なので、間に待ちがあるとその隙に
69
+ // 同じ cwd の会話が開き、再確認をすり抜けて二重に作る。実在確認の await をここへ置いて
70
+ // 実際に踏んだ (2026-09-16)。
71
+ const concurrent = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
72
+ if (concurrent) return { ok: true, session: concurrent, cwd }
73
+ const stream_id = `auto-${randomUUID()}`
74
+ if (cliKind === "codex") await bridge.attach({ stream_id, cwd })
75
+ else bridge.attach({ ...resolveClaudeOptions?.(), stream_id, cwd })
76
+ const session = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
77
+ return { ok: true, session, cwd }
78
+ } catch (err) {
79
+ const error = err?.message || String(err)
80
+ logger?.warn?.({ cwd, cliKind, error }, "chat session start failed")
81
+ return { ok: false, error }
82
+ }
83
+ })
84
+ starts.set(key, pending)
85
+ try {
86
+ return await pending
87
+ } finally {
88
+ if (starts.get(key) === pending) starts.delete(key)
89
+ }
90
+ }
@@ -0,0 +1,9 @@
1
+ import { listChatTargets } from "./chat-targets.mjs"
2
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
3
+
4
+ /** ローカル検出した宛先と、毎回取得するライブ会話を返す。 */
5
+ export async function buildChatSessionsSnapshot({ bridges, logger }) {
6
+ const live = describeLiveSessionsUnion(bridges)
7
+ const { targets, errors } = await listChatTargets({ logger })
8
+ return { targets, live, errors }
9
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { hubProjectsBase } from "./chat-workspace.mjs"
4
+ import { listWorktreeEntries } from "./tmux.mjs"
5
+ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
6
+
7
+ export const chatTargetsCache = new AsyncTtlCache({ ttlMs: 3000 })
8
+
9
+ /**
10
+ * これはローカルのディレクトリ検出であって Hub の登録一覧ではない。
11
+ *
12
+ * ⚠️ **走査が 1 つでも失敗した結果はキャッシュしない**。この一覧は送信の宛先候補そのもので、
13
+ * 権限エラー等で一部が欠けたまま TTL のあいだ配ると、実在する宛先が「無い」と判定される
14
+ * (`no_live_session_for_cwd` に化ける)。AsyncTtlCache は **例外**を投げた build をキャッシュ
15
+ * しない設計だが、失敗を戻り値へ包むとその保護が外れるため、ここで明示的に捨てる。
16
+ */
17
+ export async function listChatTargets({ logger } = {}) {
18
+ const result = await chatTargetsCache.get(async () => {
19
+ const base = hubProjectsBase()
20
+ const errors = []
21
+ let workspaces = []
22
+ let worktrees = []
23
+ try {
24
+ workspaces = (await fs.readdir(base, { withFileTypes: true }))
25
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
26
+ .map((entry) => ({ kind: "workspace", name: entry.name, cwd: path.join(base, entry.name) }))
27
+ } catch (err) {
28
+ // ⚠️ ENOENT は「base がまだ作られていない」であって走査の失敗ではない。worktree 側
29
+ // (tmux.mjs の listWorktreeEntries) と扱いを揃える。ここで errors に入れると、正常な
30
+ // 新規環境が常時エラー扱いになり、さらに下の失敗時破棄でキャッシュも毎回捨てられる。
31
+ // EACCES / ENOTDIR / I-O エラーは本物の失敗なので従来どおり errors に残す。
32
+ if (err?.code !== "ENOENT") {
33
+ errors.push({ scope: "projects-base", path: base, code: err?.code ?? null, message: err?.message || String(err) })
34
+ logger?.warn?.({ err: err?.message || String(err) }, "chat.sessions: workspace scan failed")
35
+ }
36
+ }
37
+ try {
38
+ const scan = await listWorktreeEntries()
39
+ errors.push(...scan.errors)
40
+ worktrees = scan.entries.map((entry) => ({
41
+ kind: "worktree", name: entry.name, cwd: entry.cwd,
42
+ parent_name: entry.parent_session_name, branch: entry.branch,
43
+ }))
44
+ } catch (err) {
45
+ errors.push({ scope: "projects-base", path: base, code: err?.code ?? null, message: err?.message || String(err) })
46
+ logger?.warn?.({ err: err?.message || String(err) }, "chat.sessions: worktree scan failed")
47
+ }
48
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
49
+ const seen = new Set()
50
+ const targets = [...workspaces.sort(byName), ...worktrees.sort(byName)].filter(({ cwd }) => {
51
+ if (seen.has(cwd)) return false
52
+ seen.add(cwd)
53
+ return true
54
+ })
55
+ return { targets, errors }
56
+ })
57
+ // 失敗を含む結果は次の呼び出しで再走査させる (TTL のあいだ欠けた宛先一覧を配らない)。
58
+ if (result.errors.length > 0) chatTargetsCache.clear()
59
+ return result
60
+ }
@@ -0,0 +1,279 @@
1
+ import fs from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { ensureClaudeMd } from "./claude-md.mjs"
5
+ import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
6
+ import { createSessionBudget } from "./hub-request-deadline.mjs"
7
+
8
+ // HUB_PROJECTS_BASE は実行時に解決する。テストや起動環境で
9
+ // `process.env.HUB_PROJECTS_BASE` を上書きできるよう、import 時に
10
+ // const として固定しない (tests/tmux.test.mjs が動的に差し替えるため)。
11
+ export function hubProjectsBase() {
12
+ return process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
13
+ }
14
+
15
+ /**
16
+ * 先頭の `~` / `~/` を `os.homedir()` に展開する。
17
+ *
18
+ * tmux は `-c` に渡された cwd をシェル展開しないため、フロントから
19
+ * 受け取った `~/hub/projects/...` を絶対パスに変換する必要がある。
20
+ * 既に絶対パス / 相対パスなら noop。
21
+ */
22
+ export function expandTilde(p) {
23
+ if (typeof p !== "string" || p.length === 0) return p
24
+ if (p === "~") return os.homedir()
25
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2))
26
+ return p
27
+ }
28
+
29
+ /**
30
+ * 絶対パス `p` が HUB_PROJECTS_BASE 配下かを判定する。
31
+ *
32
+ * `createSession` の cwd 自動作成判定で使う。base そのもの (`~/hub/projects`)
33
+ * は対象外とし、配下のサブディレクトリのみ true を返す。`path.relative` で
34
+ * `..` を含まないかチェックすることで `/Users/.../hub/projects-backup` のような
35
+ * 接頭辞一致のみの誤検知を排除する。
36
+ */
37
+ export function isUnderHubProjectsBase(p) {
38
+ if (typeof p !== "string" || p.length === 0) return false
39
+ const abs = path.resolve(p)
40
+ const base = path.resolve(hubProjectsBase())
41
+ if (abs === base) return false
42
+ const rel = path.relative(base, abs)
43
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Hub workspace またはその worktree から、案件指示ファイルを置く親 workspace を返す。
49
+ * worktree 内の AGENTS.md / CLAUDE.md を別管理すると Hub 正本が分岐するため、
50
+ * `<workspace>/.claude/worktrees/<name>` で起動しても常に `<workspace>` を対象にする。
51
+ */
52
+ export function projectInstructionDir(p) {
53
+ if (!isUnderHubProjectsBase(p)) return path.resolve(p)
54
+ const base = path.resolve(hubProjectsBase())
55
+ const [workspace] = path.relative(base, path.resolve(p)).split(path.sep)
56
+ return path.join(base, workspace)
57
+ }
58
+
59
+ // 親 workspace を共有する worktree も含め、同じ指示書への更新を直列化する。
60
+ const preparations = new Map()
61
+
62
+ /**
63
+ * 指示書が実際に使える状態かを **ディスクの実体**で判定する。
64
+ *
65
+ * ⚠️ 同期処理の戻り値や「例外が出なかったこと」で判定しない。`ensureClaudeMd` /
66
+ * `syncClaudeMdWithHub` は失敗を warn に潰して **正常終了**するため、EACCES 等で 1 行も
67
+ * 書けていなくても呼び出し側からは成功に見える(それが「未準備のまま会話を開く」の原因)。
68
+ * ⚠️ HUB_PROJECTS_BASE の外は同期の対象外なので常に利用可能として扱う。同期しない cwd を
69
+ * 「未準備」と判定すると、通常の作業ディレクトリで会話を開けなくなる。
70
+ * ⚠️ 見るのは「通常ファイルとして実在し、実行ユーザーが読めること」だけで、Hub との内容一致は
71
+ * 条件にしない。Hub が落ちていても、既にローカルにある指示書が読めるならその会話は開いてよい。
72
+ * ⚠️ 実在だけでは足りない。読めない指示書 (mode 000 等) は stat を通るうえ、`ensureClaudeMd` の
73
+ * 存在確認 (claude-md.mjs:300 の `fs.access`) も既定では存在しか見ないため「既存」と扱われる。
74
+ * 結果として CLI 側は 1 行も読めないのに準備成功になる。
75
+ */
76
+ async function instructionsAvailable(resolvedCwd, opts) {
77
+ if (!isUnderHubProjectsBase(resolvedCwd)) return true
78
+ const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
79
+ const file = path.join(projectInstructionDir(resolvedCwd), configFilename)
80
+ try {
81
+ // ディレクトリや symlink 先の欠落を「使える」と読まないため、通常ファイルであることを先に見る。
82
+ if (!(await fs.stat(file)).isFile()) return false
83
+ // ⚠️ ここで open せず access を使う。実際に読むのは別プロセス (CLI) なので、こちらが
84
+ // ハンドルを持つ理由がなく、持てば閉じ忘れの経路が増えるだけになる。
85
+ await fs.access(file, fs.constants.R_OK)
86
+ return true
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * ローカル準備後、既定では Hub 同期も待つ。
94
+ * `sync` は **指示書が使えるか (boolean)** に解決し、同期自体の失敗では reject しない。
95
+ *
96
+ * @param {number} [budgetMs] Hub 同期に使ってよい wall-clock (既定 `SESSION_BUDGET_MS`)。
97
+ * ⚠️ **同じ親 workspace の先行要求を待つ時間も含めて**数える(下の予算生成位置を参照)。
98
+ */
99
+ export async function prepareChatWorkspace({ cwd, session: name, waitForSync = true, budgetMs, ...opts }) {
100
+ const resolvedCwd = expandTilde(cwd)
101
+ await prepareLocalWorkspace(resolvedCwd, name, opts)
102
+ const key = projectInstructionDir(resolvedCwd)
103
+ const previous = preparations.get(key) || Promise.resolve()
104
+ // ⚠️ 予算は **キューに並ぶ前**に作る。同じ親 workspace の同期は直列化されるので、予算を
105
+ // syncWorkspace の中で作ると先行要求を待った時間が予算の外に出て、待ち行列の長さぶん
106
+ // 起動の停止時間が伸びる(tmux 側は sync 全体を待つ)。予算は生成時からの wall-clock
107
+ // なので、ここで作れば待機もそのまま期限に含まれる。切れたら Hub 往復を行わず次回へ持ち越す。
108
+ const budget = isUnderHubProjectsBase(resolvedCwd) ? createSessionBudget(budgetMs) : null
109
+ const sync = previous.catch(() => {})
110
+ .then(() => syncWorkspace(resolvedCwd, name, { ...opts, budget }))
111
+ .catch((err) => {
112
+ opts.logger?.warn?.(
113
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
114
+ "workspace sync failed (session starts anyway)",
115
+ )
116
+ })
117
+ // ⚠️ 同期の「正常終了」を準備成功と読み替えない。上の catch は syncWorkspace が
118
+ // 例外を投げたときしか通らず、内部で warn に潰された書込失敗はここまで来ない。
119
+ .then(() => instructionsAvailable(resolvedCwd, opts))
120
+ .finally(() => {
121
+ budget?.clear()
122
+ if (preparations.get(key) === sync) preparations.delete(key)
123
+ })
124
+ preparations.set(key, sync)
125
+ if (waitForSync) await sync
126
+ return { cwd: resolvedCwd, sync }
127
+ }
128
+
129
+ function isWorktreePath(resolvedCwd) {
130
+ const relative = path.relative(path.resolve(hubProjectsBase()), path.resolve(resolvedCwd))
131
+ const segments = relative.split(path.sep)
132
+ if (path.isAbsolute(relative) || segments.includes("..")) return false
133
+ return segments.some((segment, i) =>
134
+ segment === ".claude" && segments[i + 1] === "worktrees" && i + 2 < segments.length)
135
+ }
136
+
137
+ export function isCwdUnavailableError(err) {
138
+ return err?.code === "CWD_UNAVAILABLE"
139
+ }
140
+
141
+ function cwdUnavailableError(message) {
142
+ return Object.assign(new Error(message), { code: "CWD_UNAVAILABLE" })
143
+ }
144
+
145
+ async function prepareLocalWorkspace(resolvedCwd, name, opts) {
146
+ // cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
147
+ // 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
148
+ // fallback して、SessionStart hook が project を解決できなくなる。
149
+ //
150
+ // 例外: 解決後のパスが HUB_PROJECTS_BASE 配下 (= 案件 workspace ディレクトリ)
151
+ // なら、未 clone のメンバー Mac でも cockpit から起動できるよう mkdir -p で
152
+ // 自動生成する。任意の絶対パス (leader cwd 等) や HUB_PROJECTS_BASE 外は
153
+ // 引き続き `cwd not found` を返し、誤った workspace 起動を防ぐ。
154
+ try {
155
+ const st = await fs.stat(resolvedCwd)
156
+ if (!st.isDirectory()) throw cwdUnavailableError("cwd not a directory")
157
+ } catch (err) {
158
+ if (err?.code === "ENOENT") {
159
+ // worktree は git が作るもの。mkdir では .git の無い偽物ができるだけである。
160
+ // 2026-09-16 の実測でも、消えた worktree の準備が成功し .git の無いディレクトリができた。
161
+ // 削除と起動が並行すると、この mkdir が削除直後に復活させ、git worktree remove が
162
+ // 成功しても残骸を残す。自動生成の目的は未 clone のメンバー Mac で案件 workspace を
163
+ // 開けるようにすることであり、git が管理する worktree を作る理由はない。
164
+ if (isUnderHubProjectsBase(resolvedCwd) && !isWorktreePath(resolvedCwd)) {
165
+ try {
166
+ await fs.mkdir(resolvedCwd, { recursive: true })
167
+ opts.logger?.info?.(
168
+ { session: name, cwd: resolvedCwd },
169
+ "auto-created workspace dir before tmux new-session",
170
+ )
171
+ } catch (mkdirErr) {
172
+ // 原因が一時的でも、いま cwd が無ければ会話は登録できない。
173
+ // 登録すると live・配送対象に載り、送信が delivered を返してしまう。
174
+ // ただし並行する別経路の生成を無条件で弾かないよう、現在の状態を再確認する。
175
+ const st = await fs.stat(resolvedCwd).catch(() => null)
176
+ if (st?.isDirectory()) {
177
+ opts.logger?.warn?.(
178
+ { session: name, cwd: resolvedCwd, err: mkdirErr?.message || String(mkdirErr) },
179
+ "workspace mkdir failed, but directory already exists",
180
+ )
181
+ return
182
+ }
183
+ throw cwdUnavailableError(
184
+ `cwd mkdir failed: ${mkdirErr?.message || String(mkdirErr)}`,
185
+ )
186
+ }
187
+ } else {
188
+ throw cwdUnavailableError(`cwd not found: ${resolvedCwd}`)
189
+ }
190
+ } else if (err?.code === "ENOTDIR") {
191
+ throw cwdUnavailableError("cwd not a directory")
192
+ } else if (isCwdUnavailableError(err)) {
193
+ throw err
194
+ } else {
195
+ // stat の失敗だけでは cwd が使えないと確定できないため、印を付けない。
196
+ throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
197
+ }
198
+ }
199
+ }
200
+
201
+ async function syncWorkspace(resolvedCwd, name, opts) {
202
+ // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成し、
203
+ // Hub の正本(Project.claude_md)から受け取る。
204
+ // - ensureClaudeMd: 既存 CLAUDE.md は触らない。無ければ director frontmatter
205
+ // または placeholder を生成する。
206
+ // - syncClaudeMdWithHub: **受け取り専用**。Hub 側更新はローカルへ pull(.bak 退避
207
+ // してから上書き)、端末側で編集されていても Hub へは送り返さない(正本は
208
+ // プレースホルダ入りで、端末にあるのはそのレンダリング結果のため)。乖離は
209
+ // warn + `config-md-receive-only-drift` で見える形に残す。唯一の例外が
210
+ // **初回登録**(Hub の正本が空 + そのファイルが元からあった場合に 1 度だけ push)。
211
+ // Hub が一度も保存されていないプロジェクトでは何もしない(テナント標準
212
+ // CLAUDE.md の運用を尊重する)。
213
+ // どちらも失敗しても session 起動自体は止めない (warn のみ)。
214
+ // ⚠️ 2 つの呼び出しで **1 本の budget を共有**する。1 往復ごとの期限だけでは
215
+ // 往復の本数ぶん足し算されてセッション起動が長時間止まりうるため、Hub 処理に
216
+ // 割り当てた wall-clock 予算(予算の生成時からの経過時間で、通信の待ち時間の
217
+ // 累積ではない)で全体を打ち切る。予算切れなら「何も書かずに次回へ持ち越す」。
218
+ if (isUnderHubProjectsBase(resolvedCwd)) {
219
+ const instructionDir = projectInstructionDir(resolvedCwd)
220
+ const dirName = path.basename(instructionDir)
221
+ const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
222
+ // ⚠️ 予算は呼び出し側 (prepareChatWorkspace) が **キューに並ぶ前**に作って所有する。
223
+ // ここで作ると先行要求を待った時間が予算の外に出る。後片付けも所有者側で行うので、
224
+ // 受け取った予算は clear しない(自前で作った場合だけ後始末する)。
225
+ const budget = opts.budget || createSessionBudget()
226
+ const ownsBudget = !opts.budget
227
+ // ⚠️ `ensureClaudeMd` の戻り値を捨てない。`source === 'existing'` は
228
+ // 「このセッションより前からローカルにファイルがあった」= **初回登録**
229
+ // (Hub に無くローカルにあるものを 1 度だけ Hub へ取り込む)の唯一の根拠で、
230
+ // ディスクに残さずここから同期へ手渡す。例外時は **undefined のまま渡さない**
231
+ // (=登録しない。何が起きたか分からない回に Hub を触らせない安全側)。
232
+ let localFilePreexisted
233
+ try {
234
+ try {
235
+ const ensure = await ensureClaudeMd({
236
+ targetDir: instructionDir,
237
+ dirName,
238
+ hubUrl: opts.hubUrl,
239
+ accessToken: opts.hubAccessToken,
240
+ logger: opts.logger,
241
+ fetchImpl: opts.fetchImpl,
242
+ configFilename,
243
+ budget,
244
+ })
245
+ localFilePreexisted = ensure?.source === "existing"
246
+ } catch (err) {
247
+ opts.logger?.warn?.(
248
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
249
+ "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
250
+ )
251
+ }
252
+ try {
253
+ const syncResult = await syncClaudeMdWithHub({
254
+ targetDir: instructionDir,
255
+ dirName,
256
+ hubUrl: opts.hubUrl,
257
+ accessToken: opts.hubAccessToken,
258
+ logger: opts.logger,
259
+ fetchImpl: opts.fetchImpl,
260
+ configFilename,
261
+ budget,
262
+ // undefined のときはキーごと渡さない(呼び出し側の意図を明示する)。
263
+ ...(localFilePreexisted === undefined ? {} : { localFilePreexisted }),
264
+ })
265
+ opts.logger?.debug?.(
266
+ { session: name, dirName, sync: syncResult },
267
+ "syncClaudeMdWithHub completed",
268
+ )
269
+ } catch (err) {
270
+ opts.logger?.warn?.(
271
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
272
+ "syncClaudeMdWithHub failed (session starts anyway)",
273
+ )
274
+ }
275
+ } finally {
276
+ if (ownsBudget) budget.clear()
277
+ }
278
+ }
279
+ }
@@ -22,6 +22,7 @@ import { randomUUID } from "node:crypto"
22
22
  import { readFileSync } from "node:fs"
23
23
  import os from "node:os"
24
24
  import path from "node:path"
25
+ import { isUnderRemoval } from "./chat-removal-guard.mjs"
25
26
 
26
27
  import { jsonlPath } from "./claude-history.mjs"
27
28
  import { watchSessionFile } from "./claude-history-watch.mjs"
@@ -2710,10 +2711,14 @@ export class ClaudeStreamBridge extends EventEmitter {
2710
2711
  * SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
2711
2712
  * (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
2712
2713
  *
2713
- * @param {{cwd?: string, session_id?: string}} sel
2714
+ * ⚠️ `reason` は exit イベントにそのまま載る。既定の `tui-bind-stop` は上記 TUI bind 用の
2715
+ * 名前なので、**TUI 以外の理由で止めるときは必ず渡す**こと (既定のままだと、存在しない
2716
+ * tmux 連携のせいで会話が消えたように見え、原因追跡が別経路へ逸れる)。
2717
+ *
2718
+ * @param {{cwd?: string, session_id?: string, reason?: string}} sel
2714
2719
  * @returns {number} 停止したセッション数
2715
2720
  */
2716
- stopSessionsFor({ cwd, session_id } = {}) {
2721
+ stopSessionsFor({ cwd, session_id, reason = "tui-bind-stop" } = {}) {
2717
2722
  const victims = new Set()
2718
2723
  if (session_id) {
2719
2724
  // 複合キー化 (T06271) で session_id 単独では引けないため values を走査して拾う。
@@ -2757,7 +2762,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2757
2762
  this.emit("exit", {
2758
2763
  stream_id: s.stream_id,
2759
2764
  code: 0,
2760
- reason: "tui-bind-stop",
2765
+ reason,
2761
2766
  session_id: s.sessionId,
2762
2767
  })
2763
2768
  stopped++
@@ -2767,7 +2772,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2767
2772
 
2768
2773
  /** cwd と session_id が完全一致する live セッションだけを停止し、CLI 終了まで待つ。
2769
2774
  * 戻り値は今回新たに停止した数 (tombstone の待ち直しは数えない)。timeout は throw。 */
2770
- async stopSessionForDeletion({ cwd, session_id }) {
2775
+ async stopSessionForDeletion({ cwd, session_id, reason = "session-deleted" }) {
2771
2776
  const key = this._liveKeyFor(cwd, session_id)
2772
2777
  const newVictims = new Set()
2773
2778
  for (const s of [...this._liveBySession.values(), ...this.sessions.values()]) {
@@ -2802,7 +2807,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2802
2807
  this.emit("exit", {
2803
2808
  stream_id: s.stream_id,
2804
2809
  code: 0,
2805
- reason: "session-deleted",
2810
+ reason,
2806
2811
  session_id: s.sessionId,
2807
2812
  })
2808
2813
  }
@@ -2860,6 +2865,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2860
2865
  // cwd を 1 回だけ解決し、reattach lookup / session 生成 / 索引登録で同じ値を使う
2861
2866
  // (複合キーの整合性を保つため)。
2862
2867
  const scwd = cwd || process.env.HOME || process.cwd()
2868
+ if (isUnderRemoval(scwd)) throw new Error(`worktree removal in progress: ${scwd}`)
2863
2869
  // 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
2864
2870
  // 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
2865
2871
  // ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる