@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.
- package/package.json +1 -1
- package/src/async-ttl-cache.mjs +25 -11
- package/src/chat-removal-guard.mjs +62 -0
- package/src/chat-session-starter.mjs +90 -0
- package/src/chat-sessions-snapshot.mjs +110 -0
- package/src/chat-targets.mjs +60 -0
- package/src/chat-workspace.mjs +279 -0
- package/src/claude-stream-bridge.mjs +11 -5
- package/src/codex-stream-bridge.mjs +21 -1
- package/src/dispatch-scheduler.mjs +45 -5
- package/src/main.mjs +271 -17
- package/src/sibling-inject.mjs +40 -3
- package/src/tmux.mjs +54 -175
package/src/sibling-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
() =>
|
|
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 = {
|
|
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 {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
110
|
+
* worktree の配列と走査エラーを返す(同名でも別 cwd なら保持する)。
|
|
163
111
|
*
|
|
164
112
|
* - 各 worktree dir は `.git` ファイル/ディレクトリの存在で git worktree と判定
|
|
165
113
|
* - session 名はディレクトリ名を sanitize したもの (tmux session 名命名規則)
|
|
166
114
|
*
|
|
167
|
-
*
|
|
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
|
|
175
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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>` だと
|