@cocorograph/hub-agent 0.8.76 → 0.8.78

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.
@@ -80,6 +80,7 @@ import path from "node:path"
80
80
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
81
81
  import { describeLiveSessionsUnion } from "./live-sessions.mjs"
82
82
  import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
83
+ import { listChatTargets } from "./chat-targets.mjs"
83
84
 
84
85
  export const SIBLING_REQUESTS_DIR =
85
86
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
@@ -431,6 +432,7 @@ export class SiblingInjectBridge {
431
432
  pidStartedAt = defaultPidStartedAt,
432
433
  onRestartRequested,
433
434
  isInputBlocked,
435
+ starter,
434
436
  watchFn = watch,
435
437
  } = {}) {
436
438
  this.dir = dir
@@ -447,6 +449,7 @@ export class SiblingInjectBridge {
447
449
  this._pidStartedAt = pidStartedAt
448
450
  this._onRestartRequested = onRestartRequested
449
451
  this._isInputBlocked = isInputBlocked
452
+ this._starter = starter
450
453
  this._watchFn = watchFn
451
454
  this._newTurnInFlight = 0
452
455
  /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
@@ -932,7 +935,10 @@ export class SiblingInjectBridge {
932
935
  result = { ok: false, error: "invalid_token" }
933
936
  } else {
934
937
  // 判定は live-sessions.mjs に一本化する (tmux.list_sessions の warm と同じ関数)。
935
- result = { ok: true, live_sessions: describeLiveSessionsUnion(this._bridges()) }
938
+ const live_sessions = describeLiveSessionsUnion(this._bridges())
939
+ const { targets, errors } = await listChatTargets({ logger: this.logger })
940
+ const registered_targets = targets.map(({ cwd, kind, name }) => ({ cwd, kind, name }))
941
+ result = { ok: true, live_sessions, registered_targets, errors }
936
942
  }
937
943
  } else if (!cwd || (!isDelegateEvent && badKind) || badBody) {
938
944
  result = { ok: false, error: "invalid_request" }
@@ -1113,7 +1119,29 @@ export class SiblingInjectBridge {
1113
1119
  // **実配送先 (r.session)** を返す。
1114
1120
  const r = bridge?.sendToCwd
1115
1121
  ? await this._sendNewTurn(
1116
- () => bridge.sendToCwd(cwd, text, injectSource, request_id),
1122
+ async () => {
1123
+ const first = await bridge.sendToCwd(cwd, text, injectSource, request_id)
1124
+ if (first.status !== "not_found" || target_session_id || !this._starter) return first
1125
+ const started = await this._starter({ cwd, cliKind: target_cli_kind })
1126
+ // ⚠️ 起動側の失敗理由をここで捨てない。捨てると送信元には最後の
1127
+ // `no_live_session_for_cwd` しか届かず、「準備中でもう一度送れば届く」と
1128
+ // 「その cwd には恒久的に会話が無い」を区別できない。配送結果そのものは
1129
+ // not_found のままなので、診断情報として添えて下流の応答へ持ち上げる。
1130
+ if (!started?.ok) {
1131
+ return started?.error
1132
+ ? { ...first, startup_error: started.error, startup_retryable: started.retryable === true }
1133
+ : first
1134
+ }
1135
+ // ⚠️ 起動側は cwd を展開・解決してから会話を作る (chat-session-starter の
1136
+ // expandTilde と prepareChatWorkspace)。ここで展開前の cwd のまま引き直すと、
1137
+ // 実際に作られた会話 (解決後 cwd) と一致せず、起こした直後に not_found へ落ちる。
1138
+ // 解決後の cwd は起動側だけが知っているので、その戻り値を使う。
1139
+ const startedCwd = typeof started.cwd === "string" && started.cwd ? started.cwd : cwd
1140
+ const retry = await bridge.sendToCwd(startedCwd, text, injectSource, request_id)
1141
+ // 起動後の曖昧さは候補案内へ渡し、不在のときだけ最初の診断へ戻す。
1142
+ if (retry.status === "not_found") return first
1143
+ return retry
1144
+ },
1117
1145
  { request_id, cwd, target_cli_kind, op: "normal" },
1118
1146
  )
1119
1147
  : { status: "not_found" }
@@ -1159,7 +1187,16 @@ export class SiblingInjectBridge {
1159
1187
  ...ambiguousTargetOutcome(target_cli_kind, undefined, r.candidates),
1160
1188
  }
1161
1189
  } else {
1162
- result = { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
1190
+ result = {
1191
+ ok: false,
1192
+ error: "no_live_session_for_cwd",
1193
+ ...this._diagnose(cwd, target_cli_kind),
1194
+ // ⚠️ `retryable` を載せるのは自動起動が「配送前に」失敗した場合だけ。配送を試みた
1195
+ // 結果が不明な要求に再試行印を付けると、送信元の自動再送で二重配送になる。
1196
+ ...(r.startup_error
1197
+ ? { startup_error: r.startup_error, retryable: r.startup_retryable === true }
1198
+ : {}),
1199
+ }
1163
1200
  }
1164
1201
  }
1165
1202
  }
package/src/tmux.mjs CHANGED
@@ -19,9 +19,8 @@ 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"
23
- import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
24
- import { createSessionBudget } from "./hub-request-deadline.mjs"
22
+ import { expandTilde, hubProjectsBase, prepareChatWorkspace } from "./chat-workspace.mjs"
23
+ export { expandTilde, isUnderHubProjectsBase, projectInstructionDir } from "./chat-workspace.mjs"
25
24
  import { detectSessionState, getSessionCwd } from "./state.mjs"
26
25
  import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
27
26
  import { baseSessionName, sessionNameFor } from "./session-names.mjs"
@@ -32,61 +31,10 @@ const execFileP = promisify(execFile)
32
31
 
33
32
  const DEFAULT_TMUX_BIN = "tmux"
34
33
 
35
- // HUB_PROJECTS_BASE は実行時に解決する。テストや起動環境で
36
- // `process.env.HUB_PROJECTS_BASE` を上書きできるよう、import 時に
37
- // const として固定しない (tests/tmux.test.mjs が動的に差し替えるため)。
38
- function hubProjectsBase() {
39
- return process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
40
- }
41
-
42
- function sanitizeTmuxName(s) {
34
+ export function sanitizeTmuxName(s) {
43
35
  return s.replace(/[.:]/g, "-")
44
36
  }
45
37
 
46
- /**
47
- * 先頭の `~` / `~/` を `os.homedir()` に展開する。
48
- *
49
- * tmux は `-c` に渡された cwd をシェル展開しないため、フロントから
50
- * 受け取った `~/hub/projects/...` を絶対パスに変換する必要がある。
51
- * 既に絶対パス / 相対パスなら noop。
52
- */
53
- export function expandTilde(p) {
54
- if (typeof p !== "string" || p.length === 0) return p
55
- if (p === "~") return os.homedir()
56
- if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2))
57
- return p
58
- }
59
-
60
- /**
61
- * 絶対パス `p` が HUB_PROJECTS_BASE 配下かを判定する。
62
- *
63
- * `createSession` の cwd 自動作成判定で使う。base そのもの (`~/hub/projects`)
64
- * は対象外とし、配下のサブディレクトリのみ true を返す。`path.relative` で
65
- * `..` を含まないかチェックすることで `/Users/.../hub/projects-backup` のような
66
- * 接頭辞一致のみの誤検知を排除する。
67
- */
68
- export function isUnderHubProjectsBase(p) {
69
- if (typeof p !== "string" || p.length === 0) return false
70
- const abs = path.resolve(p)
71
- const base = path.resolve(hubProjectsBase())
72
- if (abs === base) return false
73
- const rel = path.relative(base, abs)
74
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false
75
- return true
76
- }
77
-
78
- /**
79
- * Hub workspace またはその worktree から、案件指示ファイルを置く親 workspace を返す。
80
- * worktree 内の AGENTS.md / CLAUDE.md を別管理すると Hub 正本が分岐するため、
81
- * `<workspace>/.claude/worktrees/<name>` で起動しても常に `<workspace>` を対象にする。
82
- */
83
- export function projectInstructionDir(p) {
84
- if (!isUnderHubProjectsBase(p)) return path.resolve(p)
85
- const base = path.resolve(hubProjectsBase())
86
- const [workspace] = path.relative(base, path.resolve(p)).split(path.sep)
87
- return path.join(base, workspace)
88
- }
89
-
90
38
  /**
91
39
  * git branch 名から worktree dir 名 (= tmux session 名) を導出する。
92
40
  * 例: "feature/multi-area" -> "feature-multi-area"
@@ -159,26 +107,31 @@ export async function createWorktreeDir(parentDir, branch) {
159
107
 
160
108
  /**
161
109
  * `~/hub/projects/<project>/.claude/worktrees/<wt>` を走査し、
162
- * worktree session 名 → 詳細情報の Map を返す。
110
+ * worktree の配列と走査エラーを返す(同名でも別 cwd なら保持する)。
163
111
  *
164
112
  * - 各 worktree dir は `.git` ファイル/ディレクトリの存在で git worktree と判定
165
113
  * - session 名はディレクトリ名を sanitize したもの (tmux session 名命名規則)
166
114
  *
167
- * 戻り値は `{ parent_session_name, cwd, branch }` の dict。cockpit (cockpit
115
+ * entries は `{ name, parent_session_name, cwd, branch }`。cockpit (cockpit
168
116
  * worktree-discovery PR 1719) が tmux.list_sessions 応答に worktree_stubs として
169
117
  * 同梱するために branch / cwd まで返す。`branch` は `git -C <wtPath> branch
170
118
  * --show-current` を best-effort で取る。
171
119
  *
172
120
  * 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
173
121
  */
174
- export async function buildWorktreeIndex() {
175
- const out = new Map()
122
+ export async function listWorktreeEntries() {
123
+ const entries = []
124
+ const errors = []
125
+ const recordError = (scope, path, err) => {
126
+ if (err?.code !== "ENOENT") errors.push({ scope, path, code: err?.code ?? null, message: err?.message || String(err) })
127
+ }
176
128
  const projectsBase = hubProjectsBase()
177
129
  let projects
178
130
  try {
179
131
  projects = await fs.readdir(projectsBase, { withFileTypes: true })
180
- } catch {
181
- return out
132
+ } catch (err) {
133
+ recordError("projects-base", projectsBase, err)
134
+ return { entries, errors }
182
135
  }
183
136
  for (const p of projects) {
184
137
  if (!p.isDirectory() || p.name.startsWith(".")) continue
@@ -186,7 +139,8 @@ export async function buildWorktreeIndex() {
186
139
  let wts
187
140
  try {
188
141
  wts = await fs.readdir(wtBase, { withFileTypes: true })
189
- } catch {
142
+ } catch (err) {
143
+ recordError("workspace", wtBase, err)
190
144
  continue
191
145
  }
192
146
  for (const wt of wts) {
@@ -195,7 +149,8 @@ export async function buildWorktreeIndex() {
195
149
  try {
196
150
  // .git が存在する = 正規 git worktree
197
151
  await fs.stat(path.join(wtPath, ".git"))
198
- } catch {
152
+ } catch (err) {
153
+ recordError("workspace", path.join(wtPath, ".git"), err)
199
154
  continue
200
155
  }
201
156
  let branch = null
@@ -206,7 +161,7 @@ export async function buildWorktreeIndex() {
206
161
  // best-effort: detached / 取得失敗時は null のままにする
207
162
  }
208
163
  const sessionName = sanitizeTmuxName(wt.name)
209
- out.set(sessionName, {
164
+ entries.push({
210
165
  name: sessionName,
211
166
  parent_session_name: sanitizeTmuxName(p.name),
212
167
  cwd: wtPath,
@@ -214,7 +169,13 @@ export async function buildWorktreeIndex() {
214
169
  })
215
170
  }
216
171
  }
217
- return out
172
+ return { entries, errors }
173
+ }
174
+
175
+ /** 既存 tmux 向け: 同名は後勝ち、走査エラーは従来どおり握りつぶす。 */
176
+ export async function buildWorktreeIndex() {
177
+ const { entries } = await listWorktreeEntries()
178
+ return new Map(entries.map((entry) => [entry.name, entry]))
218
179
  }
219
180
 
220
181
  /**
@@ -310,8 +271,11 @@ async function inspectWorktreeDirty(wtPath) {
310
271
  * - 未コミットの変更等で git remove 自体が失敗した場合はそのエラーを上位に投げる
311
272
  *
312
273
  * @param {string} name
313
- * @param {{ force?: boolean, tmuxBin?: string }} [opts]
314
- * @returns {Promise<{ name: string, wt_path: string, dirty?: boolean, changes?: string[], unpushed_commits?: number }>}
274
+ * @param {{ force?: boolean, tmuxBin?: string, stopSessions?: (wtPath: string) => Promise<number> }} [opts]
275
+ * `stopSessions` は **dirty 判定を通過したあと・dir を消す前**に 1 回だけ呼ばれる
276
+ * (**停止 API を呼んだ**本数を返す。止まったこと・終了したことの確認は行わない——本文の
277
+ * コメント参照)。投げた場合は削除へ進まない。
278
+ * @returns {Promise<{ name: string, wt_path: string, dirty?: boolean, changes?: string[], unpushed_commits?: number, stop_requested_sessions?: number }>}
315
279
  */
316
280
  export async function removeWorktree(name, opts = {}) {
317
281
  // T05945: frontend は常にベース名を送るが、万一 `<wt>__codex` が来ても worktree index を
@@ -342,6 +306,27 @@ export async function removeWorktree(name, opts = {}) {
342
306
  }
343
307
  }
344
308
 
309
+ // ⚠️ dir を消す前に、この worktree を cwd とする **会話**へ停止要求を出す (ユーザー決定
310
+ // 「会話を止めてから削除」)。会話の実体が tmux から離れたので、止める対象もセッションでは
311
+ // なく会話になる (下の tmux kill が同じ理由で存在する)。stopSessions が投げたらここで
312
+ // 中断し、dir は消さない。
313
+ //
314
+ // ⚠️ ここが保証するのは「**対象の停止 API を呼んだこと**」だけで、「止まったこと」でも
315
+ // 「プロセスが終了したこと」でもない (2026-09-16 判断)。止まったかどうかを確かめられないのは
316
+ // bridge が内部の停止失敗を握り潰すため (呼び出し側へ届かない)。終了を待たないのは、待てる
317
+ // 作りになっていないため——
318
+ // codex 側は 1 セッションに履歴取得用の一時 client が併存し (codex-stream-bridge.mjs の
319
+ // historySnapshotFreshOperations)、それらは停止要求後に終了を確認せず集合から外れる。
320
+ // 全部を終了待ちの対象に通すには bridge の client 管理を作り替えることになる。一方で
321
+ // 取り逃す害は小さい: stop() は SIGTERM を送ったうえで 3 秒後に SIGKILL を必ず飛ばすので、
322
+ // 孤児が生きるのは長くても数秒で、その間に消えた dir へ書き込むこともできない。
323
+ // 「削除の直後に、消えた cwd を指す会話が**新しく**生まれる」方は実害があるので、そちらは
324
+ // 停止ではなく削除中の attach 拒否 (chat-removal-guard.mjs) で塞いでいる。
325
+ // ⚠️ この停止を tmux.mjs の中で実装しない。bridge を import すると循環参照になるうえ、
326
+ // 「どれが生きているか」の正本は live-sessions.mjs 側にある。呼び出し元から注入させる。
327
+ const stopRequestedSessions =
328
+ typeof opts.stopSessions === "function" ? (await opts.stopSessions(resolved)) || 0 : 0
329
+
345
330
  // 走行中の session があれば kill。T05945: 同一 worktree に claude (`<wt>`) と codex
346
331
  // (`<wt>__codex`) の 2 セッションが並びうるため、両方を kill してから dir を消す
347
332
  // (片方が残ったまま dir を消すと、生存セッションの cwd が消滅した状態になる)。
@@ -373,7 +358,7 @@ export async function removeWorktree(name, opts = {}) {
373
358
  ? resolved.slice(0, -wtSegment.length - 1)
374
359
  : path.resolve(resolved, "..", "..", "..")
375
360
  await execFileP("git", ["-C", parentRepo, "worktree", "remove", "--force", resolved])
376
- return { name: sanitized, wt_path: resolved, dirty: false }
361
+ return { name: sanitized, wt_path: resolved, dirty: false, stop_requested_sessions: stopRequestedSessions }
377
362
  }
378
363
 
379
364
  /**
@@ -791,113 +776,7 @@ export async function createSession(name, cwd, opts = {}) {
791
776
  launchPaneCli && opts.cliKind === "codex"
792
777
  ? opts.codexCmd ?? buildCodexCmd(opts)
793
778
  : null
794
- // cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
795
- // 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
796
- // fallback して、SessionStart hook が project を解決できなくなる。
797
- //
798
- // 例外: 解決後のパスが HUB_PROJECTS_BASE 配下 (= 案件 workspace ディレクトリ)
799
- // なら、未 clone のメンバー Mac でも cockpit から起動できるよう mkdir -p で
800
- // 自動生成する。任意の絶対パス (leader cwd 等) や HUB_PROJECTS_BASE 外は
801
- // 引き続き `cwd not found` を返し、誤った workspace 起動を防ぐ。
802
- try {
803
- const st = await fs.stat(resolvedCwd)
804
- if (!st.isDirectory()) throw new Error("cwd not a directory")
805
- } catch (err) {
806
- if (err?.code === "ENOENT") {
807
- if (isUnderHubProjectsBase(resolvedCwd)) {
808
- try {
809
- await fs.mkdir(resolvedCwd, { recursive: true })
810
- opts.logger?.info?.(
811
- { session: name, cwd: resolvedCwd },
812
- "auto-created workspace dir before tmux new-session",
813
- )
814
- } catch (mkdirErr) {
815
- throw new Error(
816
- `cwd mkdir failed: ${mkdirErr?.message || String(mkdirErr)}`,
817
- )
818
- }
819
- } else {
820
- throw new Error(`cwd not found: ${resolvedCwd}`)
821
- }
822
- } else if (err?.message === "cwd not a directory") {
823
- throw err
824
- } else {
825
- throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
826
- }
827
- }
828
- // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成し、
829
- // Hub の正本(Project.claude_md)から受け取る。
830
- // - ensureClaudeMd: 既存 CLAUDE.md は触らない。無ければ director frontmatter
831
- // または placeholder を生成する。
832
- // - syncClaudeMdWithHub: **受け取り専用**。Hub 側更新はローカルへ pull(.bak 退避
833
- // してから上書き)、端末側で編集されていても Hub へは送り返さない(正本は
834
- // プレースホルダ入りで、端末にあるのはそのレンダリング結果のため)。乖離は
835
- // warn + `config-md-receive-only-drift` で見える形に残す。唯一の例外が
836
- // **初回登録**(Hub の正本が空 + そのファイルが元からあった場合に 1 度だけ push)。
837
- // Hub が一度も保存されていないプロジェクトでは何もしない(テナント標準
838
- // CLAUDE.md の運用を尊重する)。
839
- // どちらも失敗しても session 起動自体は止めない (warn のみ)。
840
- // ⚠️ 2 つの呼び出しで **1 本の budget を共有**する。1 往復ごとの期限だけでは
841
- // 往復の本数ぶん足し算されてセッション起動が長時間止まりうるため、Hub 処理に
842
- // 割り当てた wall-clock 予算(予算の生成時からの経過時間で、通信の待ち時間の
843
- // 累積ではない)で全体を打ち切る。予算切れなら「何も書かずに次回へ持ち越す」。
844
- if (isUnderHubProjectsBase(resolvedCwd)) {
845
- const instructionDir = projectInstructionDir(resolvedCwd)
846
- const dirName = path.basename(instructionDir)
847
- const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
848
- const budget = createSessionBudget()
849
- // ⚠️ `ensureClaudeMd` の戻り値を捨てない。`source === 'existing'` は
850
- // 「このセッションより前からローカルにファイルがあった」= **初回登録**
851
- // (Hub に無くローカルにあるものを 1 度だけ Hub へ取り込む)の唯一の根拠で、
852
- // ディスクに残さずここから同期へ手渡す。例外時は **undefined のまま渡さない**
853
- // (=登録しない。何が起きたか分からない回に Hub を触らせない安全側)。
854
- let localFilePreexisted
855
- try {
856
- try {
857
- const ensure = await ensureClaudeMd({
858
- targetDir: instructionDir,
859
- dirName,
860
- hubUrl: opts.hubUrl,
861
- accessToken: opts.hubAccessToken,
862
- logger: opts.logger,
863
- fetchImpl: opts.fetchImpl,
864
- configFilename,
865
- budget,
866
- })
867
- localFilePreexisted = ensure?.source === "existing"
868
- } catch (err) {
869
- opts.logger?.warn?.(
870
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
871
- "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
872
- )
873
- }
874
- try {
875
- const syncResult = await syncClaudeMdWithHub({
876
- targetDir: instructionDir,
877
- dirName,
878
- hubUrl: opts.hubUrl,
879
- accessToken: opts.hubAccessToken,
880
- logger: opts.logger,
881
- fetchImpl: opts.fetchImpl,
882
- configFilename,
883
- budget,
884
- // undefined のときはキーごと渡さない(呼び出し側の意図を明示する)。
885
- ...(localFilePreexisted === undefined ? {} : { localFilePreexisted }),
886
- })
887
- opts.logger?.debug?.(
888
- { session: name, dirName, sync: syncResult },
889
- "syncClaudeMdWithHub completed",
890
- )
891
- } catch (err) {
892
- opts.logger?.warn?.(
893
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
894
- "syncClaudeMdWithHub failed (session starts anyway)",
895
- )
896
- }
897
- } finally {
898
- budget.clear()
899
- }
900
- }
779
+ await prepareChatWorkspace({ ...opts, cwd: resolvedCwd, session: name })
901
780
  // 既存チェック
902
781
  // ⚠️ `-t =<name>` で完全一致を強制する。tmux の `-t <name>` は「完全一致 →
903
782
  // 先頭一致(prefix) → fnmatch」の順で解決するため、素の `-t <name>` だと