@cocorograph/hub-agent 0.6.86 → 0.6.88
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/claude-md-sync.mjs +356 -0
- package/src/main.mjs +15 -1
- package/src/pty-bridge.mjs +17 -3
- package/src/tmux.mjs +32 -4
- package/src/ws-client.mjs +82 -5
package/package.json
CHANGED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub 正本 (Project.claude_md) と workspace ローカル CLAUDE.md の双方向同期。
|
|
3
|
+
*
|
|
4
|
+
* 方針: 双方向 last-write-wins + 履歴。
|
|
5
|
+
* - Hub 側更新 → 端末 pull(ローカルを `.bak.<ts>` 退避してから全文上書き)
|
|
6
|
+
* - 端末側更新 → Hub に PUT(Hub 側は履歴に直前版を積む。directly handled by backend)
|
|
7
|
+
* - 両方変化(衝突)→ 端末側を勝者にして Hub に PUT、Hub の取り込み内容は
|
|
8
|
+
* `.bak.conflict.<ts>` に退避(後で人手マージできるよう保存)
|
|
9
|
+
* - どちらも変化なし → no-op
|
|
10
|
+
*
|
|
11
|
+
* 初回(メタファイル無し):
|
|
12
|
+
* - ローカル CLAUDE.md が空 → pull(Hub の正本をそのまま受け取り)
|
|
13
|
+
* - ローカル非空 + Hub 非空 → 衝突扱い(端末側勝者)
|
|
14
|
+
* - ローカル非空 + Hub 空 → no-op(Hub に空を上書きしない、Stage 3 で初期化検討)
|
|
15
|
+
*
|
|
16
|
+
* 失敗時のポリシー: ローカル CLAUDE.md は触らない(warn ログ + skip)。
|
|
17
|
+
* session 起動を止めない(ensureClaudeMd と同じく best-effort)。
|
|
18
|
+
*
|
|
19
|
+
* 関連: backend Stage 1 / 1.5
|
|
20
|
+
* GET /api/admin/project/claude-md-by-dir/?dir_name=...
|
|
21
|
+
* PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md }
|
|
22
|
+
*/
|
|
23
|
+
import fs from "node:fs/promises"
|
|
24
|
+
import os from "node:os"
|
|
25
|
+
import path from "node:path"
|
|
26
|
+
import crypto from "node:crypto"
|
|
27
|
+
|
|
28
|
+
const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
|
|
29
|
+
const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
|
|
30
|
+
const META_DIR = ".cockpit-sync"
|
|
31
|
+
const META_FILE = "claude-md.json"
|
|
32
|
+
const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
|
|
33
|
+
|
|
34
|
+
async function readJsonOrNull(p) {
|
|
35
|
+
try {
|
|
36
|
+
const text = await fs.readFile(p, "utf-8")
|
|
37
|
+
return JSON.parse(text)
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadAccessToken() {
|
|
44
|
+
const data = await readJsonOrNull(TOKEN_PATH)
|
|
45
|
+
return (data && typeof data.access === "string" && data.access) || null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256(s) {
|
|
49
|
+
return crypto.createHash("sha256").update(s || "", "utf-8").digest("hex")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nowIso() {
|
|
53
|
+
return new Date().toISOString()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeBakName(prefix) {
|
|
57
|
+
// ファイル名に使えるよう : を - に置換し、ミリ秒精度を保つ。
|
|
58
|
+
const ts = nowIso().replace(/[:.]/g, "-")
|
|
59
|
+
return `${prefix}.${ts}.bak`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hub から claude-md-by-dir で正本を取得する。
|
|
64
|
+
*
|
|
65
|
+
* - 404 = 該当 Project 無し(Domain 型・local_dir 型 workspace)→ null
|
|
66
|
+
* - その他のエラー → null(warn ログ)
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<null | {project_id, claude_md, claude_md_hash, claude_md_updated_at}>}
|
|
69
|
+
*/
|
|
70
|
+
async function fetchHubClaudeMd({ hubUrl, accessToken, dirName, fetchImpl, logger }) {
|
|
71
|
+
const f = fetchImpl || globalThis.fetch
|
|
72
|
+
if (!f) return null
|
|
73
|
+
const url =
|
|
74
|
+
`${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
|
|
75
|
+
`?dir_name=${encodeURIComponent(dirName)}`
|
|
76
|
+
try {
|
|
77
|
+
const res = await f(url, {
|
|
78
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
79
|
+
})
|
|
80
|
+
if (res.status === 404) return null
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
logger?.warn?.(
|
|
83
|
+
{ dirName, status: res.status },
|
|
84
|
+
"fetchHubClaudeMd: non-OK response",
|
|
85
|
+
)
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
return await res.json()
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger?.warn?.(
|
|
91
|
+
{ dirName, err: err?.message || String(err) },
|
|
92
|
+
"fetchHubClaudeMd: request failed",
|
|
93
|
+
)
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Hub に PUT で本文を上書きする。Hub 側は履歴を積んでから保存する。
|
|
100
|
+
*
|
|
101
|
+
* @returns {Promise<boolean>} 成功なら true
|
|
102
|
+
*/
|
|
103
|
+
async function putHubClaudeMd({ hubUrl, accessToken, dirName, body, fetchImpl, logger }) {
|
|
104
|
+
const f = fetchImpl || globalThis.fetch
|
|
105
|
+
if (!f) return false
|
|
106
|
+
const url =
|
|
107
|
+
`${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
|
|
108
|
+
`?dir_name=${encodeURIComponent(dirName)}`
|
|
109
|
+
try {
|
|
110
|
+
const res = await f(url, {
|
|
111
|
+
method: "PUT",
|
|
112
|
+
headers: {
|
|
113
|
+
Authorization: `Bearer ${accessToken}`,
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({ claude_md: body }),
|
|
117
|
+
})
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
logger?.warn?.(
|
|
120
|
+
{ dirName, status: res.status },
|
|
121
|
+
"putHubClaudeMd: non-OK response",
|
|
122
|
+
)
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger?.warn?.(
|
|
128
|
+
{ dirName, err: err?.message || String(err) },
|
|
129
|
+
"putHubClaudeMd: request failed",
|
|
130
|
+
)
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readLocalClaudeMd(targetDir) {
|
|
136
|
+
try {
|
|
137
|
+
return await fs.readFile(path.join(targetDir, "CLAUDE.md"), "utf-8")
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err?.code === "ENOENT") return null
|
|
140
|
+
throw err
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeLocalClaudeMd(targetDir, body) {
|
|
145
|
+
await fs.writeFile(path.join(targetDir, "CLAUDE.md"), body, "utf-8")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readMeta(targetDir) {
|
|
149
|
+
const metaPath = path.join(targetDir, META_DIR, META_FILE)
|
|
150
|
+
return await readJsonOrNull(metaPath)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function writeMeta(targetDir, meta) {
|
|
154
|
+
const metaDir = path.join(targetDir, META_DIR)
|
|
155
|
+
await fs.mkdir(metaDir, { recursive: true })
|
|
156
|
+
await fs.writeFile(
|
|
157
|
+
path.join(metaDir, META_FILE),
|
|
158
|
+
JSON.stringify(meta, null, 2),
|
|
159
|
+
"utf-8",
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 既存ローカル CLAUDE.md を .cockpit-sync/CLAUDE.md.<prefix>.<ts>.bak に退避する。
|
|
165
|
+
* 同 prefix の .bak は新しい順に BAK_KEEP 件保持し、古いものから FIFO 削除する。
|
|
166
|
+
*
|
|
167
|
+
* @param {'normal'|'conflict'} kind バックアップ種別(命名 prefix)
|
|
168
|
+
*/
|
|
169
|
+
async function backupLocalClaudeMd(targetDir, body, kind) {
|
|
170
|
+
if (body == null) return
|
|
171
|
+
const metaDir = path.join(targetDir, META_DIR)
|
|
172
|
+
await fs.mkdir(metaDir, { recursive: true })
|
|
173
|
+
const prefix = kind === "conflict" ? "CLAUDE.md.conflict" : "CLAUDE.md"
|
|
174
|
+
const bakName = makeBakName(prefix)
|
|
175
|
+
await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
|
|
176
|
+
// 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
|
|
177
|
+
const entries = await fs.readdir(metaDir).catch(() => [])
|
|
178
|
+
const sameKind = entries
|
|
179
|
+
.filter((n) => n.startsWith(`${prefix}.`) && n.endsWith(".bak"))
|
|
180
|
+
.sort() // 名前にタイムスタンプを含むので辞書順 ≒ 時系列順
|
|
181
|
+
.reverse() // 新しい順
|
|
182
|
+
const obsolete = sameKind.slice(BAK_KEEP)
|
|
183
|
+
await Promise.all(
|
|
184
|
+
obsolete.map((n) =>
|
|
185
|
+
fs.unlink(path.join(metaDir, n)).catch(() => {}),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `<targetDir>` の CLAUDE.md を Hub 正本と同期する(双方向 last-write-wins)。
|
|
192
|
+
*
|
|
193
|
+
* @param {object} args
|
|
194
|
+
* @param {string} args.targetDir 絶対パス
|
|
195
|
+
* @param {string} args.dirName workspace ディレクトリ名(Dxxxxx_... / Pxxxxx)
|
|
196
|
+
* @param {string} [args.hubUrl] Hub API base
|
|
197
|
+
* @param {string} [args.accessToken] 省略時は ~/.claude/.hub_token.json から
|
|
198
|
+
* @param {object} [args.logger]
|
|
199
|
+
* @param {Function} [args.fetchImpl] テスト注入用
|
|
200
|
+
* @returns {Promise<{ action: 'pull'|'push'|'conflict'|'noop'|'skipped', reason?: string }>}
|
|
201
|
+
*/
|
|
202
|
+
export async function syncClaudeMdWithHub({
|
|
203
|
+
targetDir,
|
|
204
|
+
dirName,
|
|
205
|
+
hubUrl,
|
|
206
|
+
accessToken,
|
|
207
|
+
logger,
|
|
208
|
+
fetchImpl,
|
|
209
|
+
} = {}) {
|
|
210
|
+
if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
|
|
211
|
+
if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
|
|
212
|
+
|
|
213
|
+
const token =
|
|
214
|
+
accessToken === undefined ? await loadAccessToken() : accessToken || null
|
|
215
|
+
if (!token) {
|
|
216
|
+
logger?.info?.({ dirName }, "syncClaudeMdWithHub: no access token, skip")
|
|
217
|
+
return { action: "skipped", reason: "no-token" }
|
|
218
|
+
}
|
|
219
|
+
const url = hubUrl || DEFAULT_HUB_API
|
|
220
|
+
|
|
221
|
+
// 1) Hub の正本を取得
|
|
222
|
+
const hub = await fetchHubClaudeMd({
|
|
223
|
+
hubUrl: url,
|
|
224
|
+
accessToken: token,
|
|
225
|
+
dirName,
|
|
226
|
+
fetchImpl,
|
|
227
|
+
logger,
|
|
228
|
+
})
|
|
229
|
+
if (!hub) {
|
|
230
|
+
return { action: "skipped", reason: "hub-not-resolvable" }
|
|
231
|
+
}
|
|
232
|
+
const hubBody = hub.claude_md || ""
|
|
233
|
+
const hubHash = hub.claude_md_hash || "" // 空文字なら Project.save 未実行 = 空本文
|
|
234
|
+
const hubUpdatedAt = hub.claude_md_updated_at || null
|
|
235
|
+
|
|
236
|
+
// 2) ローカル CLAUDE.md を読み、現在 hash を計算
|
|
237
|
+
const localBody = await readLocalClaudeMd(targetDir)
|
|
238
|
+
const localCurrentHash = localBody == null ? null : sha256(localBody)
|
|
239
|
+
|
|
240
|
+
// 3) メタファイルを読み、前回 sync の状態を取得
|
|
241
|
+
const meta = await readMeta(targetDir)
|
|
242
|
+
|
|
243
|
+
// Hub が一度も保存されていない(空本文 + updated_at が null)プロジェクトは
|
|
244
|
+
// 「Hub UI で意図的に未設定にしている」運用(テナント標準 CLAUDE.md に任せる等)の
|
|
245
|
+
// 可能性がある。端末側に何か書いてあっても push せず noop に倒す。
|
|
246
|
+
// ユーザー確定方針: 「Hub の claude_md が空のプロジェクトは何もしない」。
|
|
247
|
+
const hubInitialized = !!hubUpdatedAt
|
|
248
|
+
if (!hubInitialized) {
|
|
249
|
+
return { action: "noop", reason: "hub-uninitialized" }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 判定: hubChanged / localChanged を計算する。
|
|
253
|
+
// - hubChanged: Hub.updated_at が前回 sync 時より進んだ
|
|
254
|
+
// updated_at が null(Project.save 未実行で初期空)の場合は変化なし扱い。
|
|
255
|
+
// - localChanged: 現在の localHash が前回 sync 時の hash と異なる(=端末で編集)
|
|
256
|
+
// meta が無くてローカル非空なら、初回参加時のローカル変更扱い(衝突候補)。
|
|
257
|
+
let hubChanged, localChanged
|
|
258
|
+
if (!meta) {
|
|
259
|
+
// 初回 sync。
|
|
260
|
+
hubChanged = !!hubBody
|
|
261
|
+
if (localBody == null) {
|
|
262
|
+
localChanged = false
|
|
263
|
+
} else {
|
|
264
|
+
localChanged = !!localBody // 非空なら端末側に何かある=衝突候補
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
hubChanged = !!hubUpdatedAt && hubUpdatedAt !== meta.lastSyncedHubUpdatedAt
|
|
268
|
+
localChanged =
|
|
269
|
+
localCurrentHash != null && localCurrentHash !== meta.lastSyncedHash
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 同じ内容で書き込まれている(hash 一致)なら衝突扱いせず noop に降格。
|
|
273
|
+
if (hubChanged && localChanged && hubHash && hubHash === localCurrentHash) {
|
|
274
|
+
hubChanged = false
|
|
275
|
+
localChanged = false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 4) アクション決定
|
|
279
|
+
if (!hubChanged && !localChanged) {
|
|
280
|
+
return { action: "noop" }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (hubChanged && !localChanged) {
|
|
284
|
+
// pull: ローカルを .bak 退避 → Hub の本文で上書き
|
|
285
|
+
await backupLocalClaudeMd(targetDir, localBody, "normal")
|
|
286
|
+
await writeLocalClaudeMd(targetDir, hubBody)
|
|
287
|
+
await writeMeta(targetDir, {
|
|
288
|
+
lastSyncedHubUpdatedAt: hubUpdatedAt,
|
|
289
|
+
lastSyncedHash: hubHash || sha256(hubBody),
|
|
290
|
+
lastSyncedAt: nowIso(),
|
|
291
|
+
})
|
|
292
|
+
logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
|
|
293
|
+
return { action: "pull" }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!hubChanged && localChanged) {
|
|
297
|
+
// push: Hub に PUT。成功時にメタ更新。
|
|
298
|
+
const ok = await putHubClaudeMd({
|
|
299
|
+
hubUrl: url,
|
|
300
|
+
accessToken: token,
|
|
301
|
+
dirName,
|
|
302
|
+
body: localBody || "",
|
|
303
|
+
fetchImpl,
|
|
304
|
+
logger,
|
|
305
|
+
})
|
|
306
|
+
if (!ok) {
|
|
307
|
+
return { action: "skipped", reason: "hub-put-failed" }
|
|
308
|
+
}
|
|
309
|
+
await writeMeta(targetDir, {
|
|
310
|
+
// PUT 後の Hub.updated_at は再度 GET しないと厳密には取れないため、
|
|
311
|
+
// 「ローカル hash と Hub.updated_at は別途次回 sync で再同期」する設計。
|
|
312
|
+
// ここでは「今 push したぶんは Hub に反映済み」として、現在のローカル
|
|
313
|
+
// hash と「今の時刻」をメタに記録する(次回 sync 時に再判定可能)。
|
|
314
|
+
lastSyncedHubUpdatedAt: nowIso(),
|
|
315
|
+
lastSyncedHash: localCurrentHash,
|
|
316
|
+
lastSyncedAt: nowIso(),
|
|
317
|
+
})
|
|
318
|
+
logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
|
|
319
|
+
return { action: "push" }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
|
|
323
|
+
// Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
|
|
324
|
+
await backupLocalClaudeMd(targetDir, hubBody, "conflict")
|
|
325
|
+
const ok = await putHubClaudeMd({
|
|
326
|
+
hubUrl: url,
|
|
327
|
+
accessToken: token,
|
|
328
|
+
dirName,
|
|
329
|
+
body: localBody || "",
|
|
330
|
+
fetchImpl,
|
|
331
|
+
logger,
|
|
332
|
+
})
|
|
333
|
+
if (!ok) {
|
|
334
|
+
// PUT 失敗時はメタを更新しない(次回再試行)。ローカルは無傷。
|
|
335
|
+
return { action: "skipped", reason: "hub-put-failed-in-conflict" }
|
|
336
|
+
}
|
|
337
|
+
await writeMeta(targetDir, {
|
|
338
|
+
lastSyncedHubUpdatedAt: nowIso(),
|
|
339
|
+
lastSyncedHash: localCurrentHash,
|
|
340
|
+
lastSyncedAt: nowIso(),
|
|
341
|
+
})
|
|
342
|
+
logger?.warn?.(
|
|
343
|
+
{ dirName },
|
|
344
|
+
"syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
|
|
345
|
+
)
|
|
346
|
+
return { action: "conflict" }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 内部テスト用エクスポート
|
|
350
|
+
export const _internals = {
|
|
351
|
+
sha256,
|
|
352
|
+
readMeta,
|
|
353
|
+
writeMeta,
|
|
354
|
+
backupLocalClaudeMd,
|
|
355
|
+
BAK_KEEP,
|
|
356
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -1137,6 +1137,20 @@ export function handleTrackedPtyData(msg, ctx) {
|
|
|
1137
1137
|
}
|
|
1138
1138
|
}
|
|
1139
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
|
|
1142
|
+
* これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
|
|
1143
|
+
* 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
|
|
1144
|
+
* への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
|
|
1145
|
+
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
1146
|
+
*/
|
|
1147
|
+
export function handleUntrackedPtyData(msg, ctx) {
|
|
1148
|
+
const { stream_id } = msg
|
|
1149
|
+
if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
|
|
1150
|
+
ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1140
1154
|
async function dispatch(msg, ctx) {
|
|
1141
1155
|
const t = msg?.type || ""
|
|
1142
1156
|
try {
|
|
@@ -1203,7 +1217,7 @@ async function dispatch(msg, ctx) {
|
|
|
1203
1217
|
handleTrackedPtyData(msg, ctx)
|
|
1204
1218
|
return
|
|
1205
1219
|
}
|
|
1206
|
-
|
|
1220
|
+
handleUntrackedPtyData(msg, ctx)
|
|
1207
1221
|
return
|
|
1208
1222
|
case "pty.resize":
|
|
1209
1223
|
ctx.ptyBridge.resize({
|
package/src/pty-bridge.mjs
CHANGED
|
@@ -86,9 +86,11 @@ export class PtyBridge extends EventEmitter {
|
|
|
86
86
|
* posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
|
|
87
87
|
*
|
|
88
88
|
* 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
|
|
89
|
-
* のたびに touch する。`gcIntervalMs` 周期で
|
|
90
|
-
*
|
|
91
|
-
* pty.data / pty.resize
|
|
89
|
+
* および **pty 出力 (onData)** のたびに touch する。`gcIntervalMs` 周期で
|
|
90
|
+
* `gcStaleMs` を超えた stream を自動 detach する (= orphan GC)。**アクティブな
|
|
91
|
+
* stream は browser からの pty.data / pty.resize、または claude の出力で常に
|
|
92
|
+
* touch されるため絶対に kill されない** (生成中で出力は流れるが入力が間遠い
|
|
93
|
+
* 閲覧中の TUI チャットが reap される A-4 を出力 touch で塞ぐ)。
|
|
92
94
|
* リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
|
|
93
95
|
* 未満なので無傷。何時間も放置された孤児だけ掃除される。
|
|
94
96
|
*
|
|
@@ -382,6 +384,16 @@ export class PtyBridge extends EventEmitter {
|
|
|
382
384
|
this.coalesceState.set(stream_id, state)
|
|
383
385
|
disposables.push(
|
|
384
386
|
pty.onData((data) => {
|
|
387
|
+
// 出力でも lastSeenAt を touch する (A-4 修正)。orphan GC は lastSeenAt が
|
|
388
|
+
// gcStaleMs 古い stream を reap するが、従来は write/resize/attach でしか
|
|
389
|
+
// touch していなかったため「生成中で出力は流れているが入力が間遠い閲覧中の
|
|
390
|
+
// TUI チャット」が 10 分で reap され、以後の送信が stream_missing で落ちていた
|
|
391
|
+
// (本番 agent.log で reaped_total 多数 + 同 input_id の再送 dedup を観測)。
|
|
392
|
+
// 出力が流れている = まだ生きて使われている stream なので touch して延命する。
|
|
393
|
+
// 真にブラウザが去った stream は claude がターン完了後に redraw を止めて出力が
|
|
394
|
+
// 止まり、gcStaleMs 経過で reap される。万一の runaway 出力 orphan は maxStreams
|
|
395
|
+
// 上限の最古 reap が backstop になる。
|
|
396
|
+
this._touch(stream_id)
|
|
385
397
|
state.buf += data
|
|
386
398
|
if (!state.timer) {
|
|
387
399
|
state.timer = setTimeout(() => {
|
|
@@ -397,6 +409,8 @@ export class PtyBridge extends EventEmitter {
|
|
|
397
409
|
} else {
|
|
398
410
|
disposables.push(
|
|
399
411
|
pty.onData((data) => {
|
|
412
|
+
// 出力でも touch (A-4 修正)。理由は上の coalesce 経路のコメント参照。
|
|
413
|
+
this._touch(stream_id)
|
|
400
414
|
this.emit("output", { stream_id, data })
|
|
401
415
|
}),
|
|
402
416
|
)
|
package/src/tmux.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import path from "node:path"
|
|
|
20
20
|
import { promisify } from "node:util"
|
|
21
21
|
|
|
22
22
|
import { ensureClaudeMd } from "./claude-md.mjs"
|
|
23
|
+
import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
|
|
23
24
|
import {
|
|
24
25
|
capturePane,
|
|
25
26
|
detectInputBoxText,
|
|
@@ -657,14 +658,22 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
657
658
|
throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
|
|
658
659
|
}
|
|
659
660
|
}
|
|
660
|
-
// HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md
|
|
661
|
-
// Hub
|
|
662
|
-
// 既存 CLAUDE.md
|
|
661
|
+
// HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成し、
|
|
662
|
+
// Hub の正本(Project.claude_md)と双方向同期する。
|
|
663
|
+
// - ensureClaudeMd: 既存 CLAUDE.md は触らない。無ければ director frontmatter
|
|
664
|
+
// または placeholder を生成する。
|
|
665
|
+
// - syncClaudeMdWithHub: Hub 正本と双方向 last-write-wins 同期。Hub 側更新は
|
|
666
|
+
// ローカルへ pull(.bak 退避してから上書き)、端末側更新は Hub へ push、
|
|
667
|
+
// 両方変化は端末側を勝者にして Hub へ push(Hub の取り込み内容は
|
|
668
|
+
// .bak.conflict に退避)。Hub が一度も保存されていないプロジェクトでは
|
|
669
|
+
// 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
|
|
670
|
+
// どちらも失敗しても session 起動自体は止めない (warn のみ)。
|
|
663
671
|
if (isUnderHubProjectsBase(resolvedCwd)) {
|
|
672
|
+
const dirName = path.basename(resolvedCwd)
|
|
664
673
|
try {
|
|
665
674
|
await ensureClaudeMd({
|
|
666
675
|
targetDir: resolvedCwd,
|
|
667
|
-
dirName
|
|
676
|
+
dirName,
|
|
668
677
|
hubUrl: opts.hubUrl,
|
|
669
678
|
accessToken: opts.hubAccessToken,
|
|
670
679
|
logger: opts.logger,
|
|
@@ -676,6 +685,25 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
676
685
|
"ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
|
|
677
686
|
)
|
|
678
687
|
}
|
|
688
|
+
try {
|
|
689
|
+
const syncResult = await syncClaudeMdWithHub({
|
|
690
|
+
targetDir: resolvedCwd,
|
|
691
|
+
dirName,
|
|
692
|
+
hubUrl: opts.hubUrl,
|
|
693
|
+
accessToken: opts.hubAccessToken,
|
|
694
|
+
logger: opts.logger,
|
|
695
|
+
fetchImpl: opts.fetchImpl,
|
|
696
|
+
})
|
|
697
|
+
opts.logger?.debug?.(
|
|
698
|
+
{ session: name, dirName, sync: syncResult },
|
|
699
|
+
"syncClaudeMdWithHub completed",
|
|
700
|
+
)
|
|
701
|
+
} catch (err) {
|
|
702
|
+
opts.logger?.warn?.(
|
|
703
|
+
{ session: name, cwd: resolvedCwd, err: err?.message || String(err) },
|
|
704
|
+
"syncClaudeMdWithHub failed (session starts anyway)",
|
|
705
|
+
)
|
|
706
|
+
}
|
|
679
707
|
}
|
|
680
708
|
// 既存チェック
|
|
681
709
|
try {
|
package/src/ws-client.mjs
CHANGED
|
@@ -58,6 +58,31 @@ const PTY_BUFFER_MAX_FRAMES = 500
|
|
|
58
58
|
// 30 秒以上経過した pty.data は flush 時に破棄する。
|
|
59
59
|
const PTY_BUFFER_MAX_AGE_MS = 30_000
|
|
60
60
|
|
|
61
|
+
// agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
|
|
62
|
+
// Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
|
|
63
|
+
// `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
|
|
64
|
+
// - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
|
|
65
|
+
// 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
|
|
66
|
+
// - claude.jsonl.event / claude.event / session.event: 喪失すると新規セッションの
|
|
67
|
+
// 楽観バブルが本物へ昇格できず「生成は始まったがバブルが出ない」(S5)。
|
|
68
|
+
// - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
|
|
69
|
+
// 再 attach/復帰判断が狂う。
|
|
70
|
+
// pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
|
|
71
|
+
// リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
|
|
72
|
+
// ack/event は 1 件ずつ意味を持つ)。
|
|
73
|
+
const BUFFERED_CTRL_TYPES = new Set([
|
|
74
|
+
"pty.input.ack",
|
|
75
|
+
"pty.exit",
|
|
76
|
+
"pty.ready",
|
|
77
|
+
"pty.error",
|
|
78
|
+
"claude.jsonl.event",
|
|
79
|
+
"claude.event",
|
|
80
|
+
"session.event",
|
|
81
|
+
])
|
|
82
|
+
// 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
|
|
83
|
+
const CTRL_BUFFER_MAX_FRAMES = 200
|
|
84
|
+
const CTRL_BUFFER_MAX_AGE_MS = 30_000
|
|
85
|
+
|
|
61
86
|
export class WsClient extends EventEmitter {
|
|
62
87
|
/**
|
|
63
88
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
@@ -94,6 +119,9 @@ export class WsClient extends EventEmitter {
|
|
|
94
119
|
// pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
|
|
95
120
|
// entry: { obj, ts }
|
|
96
121
|
this.ptyOutboundBuffer = []
|
|
122
|
+
// 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
|
|
123
|
+
// 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
|
|
124
|
+
this.ctrlOutboundBuffer = []
|
|
97
125
|
// 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
|
|
98
126
|
this.lastCloseWas5xx = false
|
|
99
127
|
// 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
|
|
@@ -203,23 +231,30 @@ export class WsClient extends EventEmitter {
|
|
|
203
231
|
request_id: randomUUID(),
|
|
204
232
|
})
|
|
205
233
|
this._flushPtyBuffer()
|
|
234
|
+
this._flushCtrlBuffer()
|
|
206
235
|
this._startHeartbeat()
|
|
207
236
|
this._startBundleWatcher()
|
|
208
237
|
this.emit("open")
|
|
209
238
|
}
|
|
210
239
|
|
|
211
|
-
/** メッセージを送る。未接続時は pty.data
|
|
240
|
+
/** メッセージを送る。未接続時は pty.data と制御/イベント系 (ack / jsonl.event 等) を
|
|
241
|
+
* buffer に積み、reconnect 後に flush する。
|
|
212
242
|
*
|
|
213
|
-
* heartbeat / hello / agent.streams.sync.*
|
|
214
|
-
* buffer しない (warn のみ)。
|
|
243
|
+
* heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
|
|
244
|
+
* buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
|
|
215
245
|
*/
|
|
216
246
|
send(obj) {
|
|
217
247
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
218
|
-
|
|
248
|
+
const type = obj?.type
|
|
249
|
+
if (type === "pty.data") {
|
|
219
250
|
this._bufferPtyData(obj)
|
|
220
251
|
return false
|
|
221
252
|
}
|
|
222
|
-
|
|
253
|
+
if (BUFFERED_CTRL_TYPES.has(type)) {
|
|
254
|
+
this._bufferCtrl(obj)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
this.logger?.warn({ type }, "ws send skipped (not open)")
|
|
223
258
|
return false
|
|
224
259
|
}
|
|
225
260
|
return this._sendJson(obj)
|
|
@@ -269,6 +304,48 @@ export class WsClient extends EventEmitter {
|
|
|
269
304
|
)
|
|
270
305
|
}
|
|
271
306
|
|
|
307
|
+
/** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
|
|
308
|
+
_bufferCtrl(obj) {
|
|
309
|
+
this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
|
|
310
|
+
if (this.ctrlOutboundBuffer.length > CTRL_BUFFER_MAX_FRAMES) {
|
|
311
|
+
const dropped = this.ctrlOutboundBuffer.length - CTRL_BUFFER_MAX_FRAMES
|
|
312
|
+
this.ctrlOutboundBuffer.splice(0, dropped)
|
|
313
|
+
this.logger?.warn(
|
|
314
|
+
{ dropped, kept: this.ctrlOutboundBuffer.length },
|
|
315
|
+
"ctrl outbound buffer overflow (oldest dropped)"
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
|
|
321
|
+
* 構造は _flushPtyBuffer と同じ (退避 → 期限切れ skip → 送信失敗で残りを順序保持で戻す)。
|
|
322
|
+
*/
|
|
323
|
+
_flushCtrlBuffer() {
|
|
324
|
+
if (this.ctrlOutboundBuffer.length === 0) return
|
|
325
|
+
const now = Date.now()
|
|
326
|
+
const buf = this.ctrlOutboundBuffer
|
|
327
|
+
this.ctrlOutboundBuffer = []
|
|
328
|
+
let sent = 0
|
|
329
|
+
let expired = 0
|
|
330
|
+
for (let i = 0; i < buf.length; i++) {
|
|
331
|
+
const entry = buf[i]
|
|
332
|
+
if (now - entry.ts > CTRL_BUFFER_MAX_AGE_MS) {
|
|
333
|
+
expired += 1
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const ok = this._sendJson(entry.obj)
|
|
337
|
+
if (!ok) {
|
|
338
|
+
this.ctrlOutboundBuffer = buf.slice(i).concat(this.ctrlOutboundBuffer)
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
sent += 1
|
|
342
|
+
}
|
|
343
|
+
this.logger?.info(
|
|
344
|
+
{ sent, expired, total: buf.length, requeued: this.ctrlOutboundBuffer.length },
|
|
345
|
+
"ctrl outbound buffer flushed"
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
272
349
|
/** Reconnect を止めて切断する。 */
|
|
273
350
|
stop() {
|
|
274
351
|
this.stopped = true
|