@cocorograph/hub-agent 0.8.38 → 0.8.39

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.38",
3
+ "version": "0.8.39",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,6 +23,16 @@
23
23
  * 詳細な条件と、なぜ `localFilePreexisted` を永続化しないのかは
24
24
  * `syncClaudeMdWithHub` 内の `isInitialRegistration` のコメントを参照。
25
25
  *
26
+ * **AGENTS.md は受け取り専用**(2026-08-09)。Hub の正本は `Project.claude_md` 1 本で、
27
+ * GET は `cli_kind` に応じて CLI 別へレンダリングして返す。AGENTS.md はその
28
+ * **レンダリング結果**であって正本ではないので、
29
+ * - GET には必ず `cli_kind=codex` を載せる(載せないと CLAUDE.md 向け本文を掴む)
30
+ * - **PUT は一切行わない**(push / conflict のどちらも)。乖離は warn +
31
+ * `agents-md-receive-only-drift` で見える形にし、ローカルも黙って上書きしない
32
+ * `cli_kind` を載せていなかった頃に作られた壊れた同期状態(AGENTS.md の base に
33
+ * CLAUDE.md 本文が入っている)は、sync の冒頭で meta / base を捨てて初回 sync へ戻す
34
+ * (`migrateBrokenAgentsBase`)。
35
+ *
26
36
  * 失敗時のポリシー: ローカル CLAUDE.md は触らない(warn ログ + skip)。
27
37
  * session 起動を止めない(ensureClaudeMd と同じく best-effort)。
28
38
  *
@@ -58,6 +68,35 @@ function configPaths(configFilename = "CLAUDE.md") {
58
68
  }
59
69
  }
60
70
 
71
+ /**
72
+ * backend へ渡す `cli_kind`(レンダリング先の CLI 種別)を設定ファイル名から導出する。
73
+ *
74
+ * Hub は `Project.claude_md` を唯一の正本として持ち、**GET のたびに CLI 別へ
75
+ * レンダリングして返す**(`cli_kind=codex` なら AGENTS.md 向け)。省略すると
76
+ * `claude` 扱いになるため、Codex セッションで載せ忘れると **CLAUDE.md 向けの本文を
77
+ * AGENTS.md へ書き込む**ことになる(実際にそうなっていた)。
78
+ *
79
+ * ⚠️ 判定は `configPaths().filename` を通す(`configFilename` の生値で分岐すると、
80
+ * 未知の値の扱いがこの関数とファイル名解決とで食い違う)。
81
+ */
82
+ function cliKindForConfig(configFilename) {
83
+ return configPaths(configFilename).filename === "AGENTS.md" ? "codex" : "claude"
84
+ }
85
+
86
+ /**
87
+ * `claude-md-by-dir` の URL を組み立てる(GET / PUT 共通)。
88
+ *
89
+ * ⚠️ `cliKind` は **GET のときだけ** 渡す。PUT はレンダリング結果ではなく正本そのものを
90
+ * 送る経路で、backend 側の解釈が GET と同じとは限らないため、こちらの都合で
91
+ * 勝手にパラメータを増やさない。
92
+ */
93
+ function claudeMdByDirUrl({ hubUrl, dirName, cliKind }) {
94
+ const base =
95
+ `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
96
+ `?dir_name=${encodeURIComponent(dirName)}`
97
+ return cliKind ? `${base}&cli_kind=${encodeURIComponent(cliKind)}` : base
98
+ }
99
+
61
100
  async function readJsonOrNull(p) {
62
101
  try {
63
102
  const text = await fs.readFile(p, "utf-8")
@@ -298,14 +337,19 @@ async function fetchHubClaudeMd({
298
337
  dirName,
299
338
  fetchImpl,
300
339
  logger,
340
+ configFilename = "CLAUDE.md",
301
341
  timeoutMs = HUB_REQUEST_TIMEOUT_MS,
302
342
  budget,
303
343
  }) {
304
344
  const f = fetchImpl || globalThis.fetch
305
345
  if (!f) return null
306
- const url =
307
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
308
- `?dir_name=${encodeURIComponent(dirName)}`
346
+ // ⚠️ `cli_kind` を必ず載せる。省略すると backend は claude 向けにレンダリングする
347
+ // ので、Codex セッションが CLAUDE.md 向け本文を AGENTS.md の正本として掴む。
348
+ const url = claudeMdByDirUrl({
349
+ hubUrl,
350
+ dirName,
351
+ cliKind: cliKindForConfig(configFilename),
352
+ })
309
353
  const deadline = acquireDeadline(budget, timeoutMs)
310
354
  try {
311
355
  const fetched = await deadline.guard(
@@ -571,9 +615,7 @@ async function putHubClaudeMd({
571
615
  }) {
572
616
  const f = fetchImpl || globalThis.fetch
573
617
  if (!f) return { ok: false, status: null, data: null, error: null }
574
- const url =
575
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
576
- `?dir_name=${encodeURIComponent(dirName)}`
618
+ const url = claudeMdByDirUrl({ hubUrl, dirName })
577
619
  // expected_hash は **string なら必ず載せる(空文字を含む)**。
578
620
  //
579
621
  // ⚠️ 空文字は「Hub が空であることを期待する」という **有効な CAS トークン**である。
@@ -668,6 +710,97 @@ function putResponseUpdatedAt(data) {
668
710
  return typeof v === "string" && v ? v : null
669
711
  }
670
712
 
713
+ /**
714
+ * **同じ本文を再送しても永久に通らない** 400 拒否の一覧(backend の `error` 値 → reason)。
715
+ *
716
+ * リトライ前提の通信失敗(`hub-put-failed`)と混ぜないために分ける。理由文字列は
717
+ * 拒否ごとに変える — 「CLI 種別を取り違えた」と「プレースホルダを失った平坦化本文を
718
+ * 送った」は原因も直し方も違うので、ログと戻り値で区別できないと調査が止まる。
719
+ *
720
+ * - `config_md_kind_mismatch` … CLAUDE.md の正本へ AGENTS.md の内容を送った等
721
+ * - `config_md_flattened` … レンダリング結果(プレースホルダが展開済み)を正本として
722
+ * 送った。正本にはあるプレースホルダが保存本文に無いので backend が拒否する
723
+ */
724
+ const PERMANENT_PUT_REJECTIONS = {
725
+ config_md_kind_mismatch: "hub-put-kind-mismatch",
726
+ config_md_flattened: "hub-put-flattened",
727
+ }
728
+
729
+ /**
730
+ * PUT の拒否理由が「再送しても通らない種類」なら、その reason を返す(違えば null)。
731
+ *
732
+ * ⚠️ 判定は 1 か所に束ねる(push 経路と conflict 経路で条件が分岐すると、片方だけ
733
+ * 新しい拒否理由に対応していない状態が生まれる)。呼び出し側は接尾辞を足すだけ。
734
+ */
735
+ function permanentPutRejectionReason(putError) {
736
+ const key = putError?.error
737
+ return (typeof key === "string" && PERMANENT_PUT_REJECTIONS[key]) || null
738
+ }
739
+
740
+ /**
741
+ * 「AGENTS.md の base に CLAUDE.md 本文が入っている」壊れた同期状態か。
742
+ *
743
+ * `cli_kind` を載せずに GET していた頃、Codex セッションは CLAUDE.md 向けの本文を
744
+ * 掴んで AGENTS.md 系の base / meta に書いていた。その base を残したまま新しい
745
+ * (AGENTS.md 向けにレンダリングされた)本文を受け取ると、3-way merge の base が
746
+ * 別 CLI の本文を指したままになる。
747
+ *
748
+ * ⚠️ 判定は **先頭の意味のある行**(空行を飛ばした最初の行)だけで行い、meta の
749
+ * `baseHash` の有無に依存させない。実端末の `agents-md.json` には `baseHash` キーが
750
+ * 無い(旧バージョンが書いた meta)ため、それを条件にすると移行が発火しない。
751
+ */
752
+ function looksLikeClaudeMdBody(text) {
753
+ if (typeof text !== "string") return false
754
+ for (const rawLine of text.split("\n")) {
755
+ const line = rawLine.trim()
756
+ if (line === "") continue
757
+ return /^#{1,6}\s*CLAUDE\.md\b/.test(line)
758
+ }
759
+ return false
760
+ }
761
+
762
+ /**
763
+ * 壊れた AGENTS.md の同期状態(base に CLAUDE.md 本文)を捨てて、次回を初回 sync に戻す。
764
+ *
765
+ * 1. ローカル本文を `.bak` へ退避(手元の内容を失わせない)
766
+ * 2. meta → base の順で削除する。**meta を先に消す**のは、meta だけ残ると
767
+ * `repairBaseIfInconsistent` が「ローカル本文で base を書き直す」経路に入り、
768
+ * 壊れた base を作り直しかねないため
769
+ *
770
+ * 呼び出し側はこの回をそこで終える(同一実行内で続けて判定しない = 中間状態を作らない)。
771
+ *
772
+ * @returns {Promise<boolean>} 移行を実行したら true
773
+ */
774
+ async function migrateBrokenAgentsBase({ targetDir, configFilename, logger, dirName }) {
775
+ const { filename, metaFile, baseFile } = configPaths(configFilename)
776
+ if (filename !== "AGENTS.md") return false
777
+ const base = await readBaseBody(targetDir, configFilename)
778
+ if (!looksLikeClaudeMdBody(base)) return false
779
+
780
+ const localBody = await readLocalConfigMd(targetDir, configFilename)
781
+ await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
782
+ const metaDir = path.join(targetDir, META_DIR)
783
+ const unlinkIfPresent = async (name) => {
784
+ try {
785
+ await fs.unlink(path.join(metaDir, name))
786
+ } catch (err) {
787
+ if (err?.code !== "ENOENT") throw err
788
+ }
789
+ }
790
+ await unlinkIfPresent(metaFile)
791
+ await unlinkIfPresent(baseFile)
792
+ logger?.warn?.(
793
+ {
794
+ dirName,
795
+ config_filename: filename,
796
+ base_bytes: Buffer.byteLength(base, "utf-8"),
797
+ local_backed_up: localBody != null,
798
+ },
799
+ "syncClaudeMdWithHub: AGENTS.md sync state held CLAUDE.md content; discarded meta/base so the next sync starts fresh",
800
+ )
801
+ return true
802
+ }
803
+
671
804
  async function readLocalConfigMd(targetDir, configFilename) {
672
805
  try {
673
806
  return await fs.readFile(path.join(targetDir, configPaths(configFilename).filename), "utf-8")
@@ -946,15 +1079,35 @@ export async function syncClaudeMdWithHub({
946
1079
  )
947
1080
  return { action: "skipped", reason: "budget-exhausted" }
948
1081
  }
1082
+
1083
+ // 0) 壊れた同期状態(AGENTS.md の base に CLAUDE.md 本文)の移行。
1084
+ //
1085
+ // ⚠️ **通信より前・判定より前**に置き、実行したらその回はそこで終える。同一実行内で
1086
+ // 続けて判定すると「meta を消した直後の初回 sync」が同じ回に走り、移行が中途半端に
1087
+ // 効いた中間状態を作る(次回セッションの初回 sync で pull させれば足りる)。
1088
+ try {
1089
+ if (await migrateBrokenAgentsBase({ targetDir, configFilename, logger, dirName })) {
1090
+ return { action: "skipped", reason: "agents-md-base-migrated" }
1091
+ }
1092
+ } catch (err) {
1093
+ // 退避 / 削除に失敗した回は同期を進めない(壊れた base を基準に使わせない)。
1094
+ logger?.warn?.(
1095
+ { dirName, err: err?.message || String(err) },
1096
+ "syncClaudeMdWithHub: failed to discard the broken AGENTS.md sync state; skip (retry next sync)",
1097
+ )
1098
+ return { action: "skipped", reason: "agents-md-base-migration-failed" }
1099
+ }
1100
+
949
1101
  const url = hubUrl || DEFAULT_HUB_API
950
1102
 
951
- // 1) Hub の正本を取得
1103
+ // 1) Hub の正本を取得(`cli_kind` を載せて CLI 別レンダリング結果を受け取る)
952
1104
  const hub = await fetchHubClaudeMd({
953
1105
  hubUrl: url,
954
1106
  accessToken: token,
955
1107
  dirName,
956
1108
  fetchImpl,
957
1109
  logger,
1110
+ configFilename,
958
1111
  timeoutMs: requestTimeoutMs,
959
1112
  budget,
960
1113
  })
@@ -1291,6 +1444,39 @@ export async function syncClaudeMdWithHub({
1291
1444
  return { action: "pull" }
1292
1445
  }
1293
1446
 
1447
+ // ---- AGENTS.md は受け取り専用(ここから下は必ず localChanged === true)--------
1448
+ //
1449
+ // Hub は `Project.claude_md`(CLAUDE.md 正本)を CLI 別にレンダリングして返す。
1450
+ // AGENTS.md はその **レンダリング結果** であって正本ではないので、端末側で編集された
1451
+ // からといって Hub へ送り返してはいけない(正本にプレースホルダが残っていても、
1452
+ // 送るのは展開済みの平坦な本文になる = backend の平坦化ガードに 400 で弾かれる)。
1453
+ //
1454
+ // ⚠️ **push 経路と conflict 経路で別々に判定しない**。ここから下は両方とも
1455
+ // 「ローカルが(前回同期時点から)変化した」経路で、PUT する / しないの分岐は
1456
+ // 1 か所に置く。経路ごとに掛けると、片方だけ塞がった状態が生まれる。
1457
+ //
1458
+ // ⚠️ **ローカルを黙って上書きもしない**。conflict 側で「端末が勝てないなら Hub で
1459
+ // 上書き」に倒すと、退避 `.bak` は残っても人は気づかず、端末の編集が事実上消える。
1460
+ // 乖離は **warn + 専用の reason** で見える形にして残し、取り込みは人に委ねる。
1461
+ //
1462
+ // ⚠️ ログに本文を載せないこと(接続情報が混ざりうる)。出してよいのは長さ・行数・
1463
+ // ハッシュのような、中身を復元できない指標だけ。
1464
+ if (configPaths(configFilename).filename === "AGENTS.md") {
1465
+ logger?.warn?.(
1466
+ {
1467
+ dirName,
1468
+ config_filename: "AGENTS.md",
1469
+ hub_changed: hubChanged,
1470
+ local_bytes: localBody == null ? 0 : Buffer.byteLength(localBody, "utf-8"),
1471
+ local_lines: localBody == null ? 0 : localBody.split("\n").length,
1472
+ local_hash: localCurrentHash,
1473
+ hub_hash: hubHash,
1474
+ },
1475
+ "syncClaudeMdWithHub: local AGENTS.md drifted from the rendered hub source of truth; AGENTS.md is receive-only so nothing was pushed and nothing was overwritten (edit CLAUDE.md on Hub, then let the next sync pull)",
1476
+ )
1477
+ return { action: "skipped", reason: "agents-md-receive-only-drift" }
1478
+ }
1479
+
1294
1480
  if (!hubChanged && localChanged) {
1295
1481
  // ⚠️ hash の観測と snapshot の自己整合性は **入口の門で保証済み**。ここに
1296
1482
  // 個別チェックを再掲しないこと(検査の本体が 2 つになると、片方だけ適用範囲が
@@ -1320,14 +1506,15 @@ export async function syncClaudeMdWithHub({
1320
1506
  )
1321
1507
  return { action: "skipped", reason: "hub-put-stale" }
1322
1508
  }
1323
- if (put.error?.error === "config_md_kind_mismatch") {
1324
- // CLI 種別の取り違え(AGENTS.md の内容を CLAUDE.md 正本へ送った等)。
1325
- // 同じ本文を再送しても永久に通らないので、リトライ前提の通信失敗と混ぜない。
1509
+ const permanent = permanentPutRejectionReason(put.error)
1510
+ if (permanent) {
1511
+ // CLI 種別の取り違え / 平坦化本文の送信。**同じ本文を再送しても永久に通らない**
1512
+ // ので、リトライ前提の通信失敗と混ぜない(meta も進めない)。
1326
1513
  logger?.warn?.(
1327
1514
  { dirName, ...put.error },
1328
- "syncClaudeMdWithHub: push rejected (config md kind mismatch); resending the same body will keep failing",
1515
+ "syncClaudeMdWithHub: push rejected by a permanent guard; resending the same body will keep failing",
1329
1516
  )
1330
- return { action: "skipped", reason: "hub-put-kind-mismatch" }
1517
+ return { action: "skipped", reason: permanent }
1331
1518
  }
1332
1519
  return { action: "skipped", reason: "hub-put-failed" }
1333
1520
  }
@@ -1389,13 +1576,14 @@ export async function syncClaudeMdWithHub({
1389
1576
  )
1390
1577
  return { action: "skipped", reason: "hub-put-stale-in-conflict" }
1391
1578
  }
1392
- if (put.error?.error === "config_md_kind_mismatch") {
1579
+ const permanent = permanentPutRejectionReason(put.error)
1580
+ if (permanent) {
1393
1581
  // push 経路と同じ理由(再送しても通らない拒否)。通信失敗と区別する。
1394
1582
  logger?.warn?.(
1395
1583
  { dirName, ...put.error },
1396
- "syncClaudeMdWithHub: conflict push rejected (config md kind mismatch); resending the same body will keep failing",
1584
+ "syncClaudeMdWithHub: conflict push rejected by a permanent guard; resending the same body will keep failing",
1397
1585
  )
1398
- return { action: "skipped", reason: "hub-put-kind-mismatch-in-conflict" }
1586
+ return { action: "skipped", reason: `${permanent}-in-conflict` }
1399
1587
  }
1400
1588
  return { action: "skipped", reason: "hub-put-failed-in-conflict" }
1401
1589
  }
@@ -1441,6 +1629,11 @@ export {
1441
1629
  readBaseBody,
1442
1630
  nowIso,
1443
1631
  configPaths,
1632
+ // `cli_kind` の導出と URL 組み立ては初期化経路(claude-md.mjs)でも使う。
1633
+ // ⚠️ 複製しないこと(片方だけ `cli_kind` を載せ忘れると、初回配信だけ別 CLI 向けの
1634
+ // 本文を掴むという非対称が生まれる)。
1635
+ cliKindForConfig,
1636
+ claudeMdByDirUrl,
1444
1637
  }
1445
1638
 
1446
1639
  // 内部テスト用エクスポート
@@ -1458,4 +1651,7 @@ export const _internals = {
1458
1651
  // 壊れるため、両者を突き合わせるテストから参照できるように公開する。
1459
1652
  makeBakName,
1460
1653
  bakNamePattern,
1654
+ cliKindForConfig,
1655
+ claudeMdByDirUrl,
1656
+ looksLikeClaudeMdBody,
1461
1657
  }
package/src/claude-md.mjs CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  nowIso,
41
41
  isHubSnapshotTrustworthy,
42
42
  usableHubUpdatedAt,
43
+ cliKindForConfig,
44
+ claudeMdByDirUrl,
43
45
  } from "./claude-md-sync.mjs"
44
46
  import {
45
47
  HUB_REQUEST_TIMEOUT_MS,
@@ -92,12 +94,18 @@ async function fetchHubClaudeMdProject({
92
94
  fetchImpl,
93
95
  deadline,
94
96
  logger,
97
+ configFilename = "CLAUDE.md",
95
98
  }) {
96
99
  const f = fetchImpl || globalThis.fetch
97
100
  if (!f) return null
98
- const url =
99
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
100
- `?dir_name=${encodeURIComponent(dirName)}`
101
+ // ⚠️ 同期側(`fetchHubClaudeMd`)と **同じ導出・同じ URL 組み立て**を使う。初回配信だけ
102
+ // `cli_kind` を落とすと、Codex の初回セッションが CLAUDE.md 向けの本文を AGENTS.md
103
+ // として書き込み、その本文が base / meta(同期の基準)にまで入る。
104
+ const url = claudeMdByDirUrl({
105
+ hubUrl,
106
+ dirName,
107
+ cliKind: cliKindForConfig(configFilename),
108
+ })
101
109
  try {
102
110
  const fetched = await deadline.guard(
103
111
  // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
@@ -325,6 +333,7 @@ export async function ensureClaudeMd({
325
333
  fetchImpl,
326
334
  deadline,
327
335
  logger,
336
+ configFilename,
328
337
  })
329
338
  } finally {
330
339
  deadline.clear()