@bd7pil/reeve 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  > **In English:** this package is the whole REEVE system for opencode v2: eight
4
4
  > agent definitions, the review skill, the task ledger with concurrency gates,
5
- > wall-clock supervision and cross-restart reconciliation, the self-hosted
6
- > Bifrost proxy with chain hot-reload, and performance stats. It fills the
5
+ > wall-clock supervision and cross-restart reconciliation, the in-process
6
+ > GATE proxy layer (routing, fallback chains, ledger, model catalog), and
7
+ > performance stats. It fills the
7
8
  > gaps v2 leaves open (`task_status`, silent child death, concurrency limits,
8
9
  > timeouts) without ever writing `agents.*.model` — that key belongs to
9
10
  > openchamber. Install with `{ "model": "reeve/pro", "plugins":
10
11
  > ["@bd7pil/reeve"] }` and nothing else needs to live in the config directory.
11
12
  > The rest of this document is the detailed Chinese reference.
12
13
 
13
- 包里就是**整个系统**:8 个 agent + review skill + 记账/闸门/墙钟/对账 + 代理自持(Bifrost 链热更)+ 性能统计。
14
+ 包里就是**整个系统**:8 个 agent + review skill + 记账/闸门/墙钟/对账 + GATE 代理层(路由/回退/台账/目录,热更 ≤5s)+ 性能统计。
14
15
  配置目录里不需要放任何 REEVE 文件——装插件、写两行配置即可(见下「安装」)。
15
16
 
16
17
  v2 原生缺的那几条,全靠这个插件补齐:
@@ -0,0 +1,49 @@
1
+ {
2
+ "//": "GATE 的兜底配置(机器上没有任何 reeve-fallback.jsonc 时用它)。刻意只放付费候选——免费车道(9router)属于每台机器自己的选择,写在用户配置里。",
3
+ "//cells": "providers.<id>:base_url + path(默认 /v1/chat/completions)+ auth(auth.json 里的 id,按序取第一个有 key 的)+ key(env.X 引用或公开字面量,覆盖 auth)+ lane(native | passthrough)+ models/models_fetch(目录来源)。",
4
+ "//tiers": "chain_for_model 里的 `reeve/*` 三条 = 引擎档位模型(插件注册的 reeve/flash|pro|multimodal)。引擎发**全名**进请求体,键必须写全名。没有免费车道时:flash → tier2a(便宜付费链)、multimodal → tier2a(bundle 里没有视觉专链)。",
5
+ "schema": 1,
6
+ "policy": {
7
+ "retry_on_errors": [401, 402, 403, 429, 500, 503, 529],
8
+ "max_fallback_attempts": 3,
9
+ "cooldown_seconds": 60,
10
+ "restore_primary_after_cooldown": false,
11
+ "first_prompt_watchdog_ms": 90000,
12
+ "provider_retries": 1,
13
+ "models_ttl_seconds": 300,
14
+ "session_ttl_seconds": 1800
15
+ },
16
+ "providers": {
17
+ "9router": { "base_url": "http://127.0.0.1:20128", "key": "none", "models": ["*"], "models_fetch": true },
18
+ "zhipuai-coding-plan": { "base_url": "https://open.bigmodel.cn", "path": "/api/coding/paas/v4/chat/completions", "auth": ["zhipuai-coding-plan"] },
19
+ "xxyapi-deepseek": { "base_url": "https://www.xxyapi.com", "auth": ["xxyapi-deepseek"] },
20
+ "lmuai": { "base_url": "https://api.lmuai.com", "auth": ["lmuai", "lmuai-glm"] },
21
+ "deepseek": { "base_url": "https://api.deepseek.com", "auth": ["deepseek", "deepseek-api"] },
22
+ "xiaomi": { "base_url": "https://api.xiaomimimo.com", "auth": ["xiaomi"] },
23
+ "alibaba-cn": { "base_url": "https://dashscope.aliyuncs.com", "path": "/compatible-mode/v1/chat/completions", "auth": ["alibaba-cn"] },
24
+ "opencode": { "base_url": "https://opencode.ai/inference/openai/v1", "path": "/chat/completions", "key": "public", "lane": "passthrough", "models": ["*"] }
25
+ },
26
+ "chains": {
27
+ "tier1": { "targets": ["zhipuai-coding-plan/glm-5.3", "deepseek/deepseek-flash"] },
28
+ "tier2a": { "targets": ["zhipuai-coding-plan/glm-5.3-flash", "xxyapi-deepseek/deepseek-v4.1-flash", "lmuai/glm-5.3-flash", "deepseek/deepseek-flash"] },
29
+ "default": { "targets": ["zhipuai-coding-plan/glm-5.3-flash", "xxyapi-deepseek/deepseek-v4.1-flash", "deepseek/deepseek-flash"] }
30
+ },
31
+ "agents": {
32
+ "planner": "tier1",
33
+ "advisor": "tier1",
34
+ "critic": "tier1",
35
+ "foreman": "tier2a",
36
+ "executor": "tier2a",
37
+ "reviewer": "tier2a",
38
+ "scout": "default",
39
+ "librarian": "default"
40
+ },
41
+ "chain_for_model": {
42
+ "reeve/flash": "tier2a",
43
+ "reeve/pro": "tier1",
44
+ "reeve/multimodal": "tier2a",
45
+ "zhipuai-coding-plan/glm-5.3": "tier1",
46
+ "zhipuai-coding-plan/glm-5.3-flash": "tier2a"
47
+ },
48
+ "default_chain": "default"
49
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bd7pil/reeve",
3
- "version": "0.1.4",
4
- "description": "REEVE 编排层(opencode v2 插件):agent/skill 注册 + 子任务台账 + 代理自持(Bifrost 链热更)+ 性能统计",
3
+ "version": "0.1.6",
4
+ "description": "REEVE 编排层(opencode v2 插件):agent/skill 注册 + 子任务台账 + GATE 代理层(路由/回退/台账)+ 性能统计",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test/unit.mjs && node test/reconcile.mjs && node test/snapshot.mjs && node test/proxy.mjs && node test/chain.mjs && node test/stats.mjs && node test/entry.mjs",
14
+ "test": "node test/unit.mjs && node test/reconcile.mjs && node test/snapshot.mjs && node test/gate.mjs && node test/guard.mjs && node test/stats.mjs && node test/entry.mjs && node test/wiring.mjs && node test/runtime.mjs",
15
15
  "test:coverage": "c8 --reporter=text --reporter=json-summary --reports-dir=coverage --exclude=test/** --exclude=bench.mjs npm test",
16
16
  "check:coverage": "node ../scripts/check-coverage.mjs",
17
17
  "prepublishOnly": "npm test"
@@ -21,7 +21,7 @@
21
21
  "src",
22
22
  "agents",
23
23
  "skills",
24
- "default-chain.json",
24
+ "default-fallback.json",
25
25
  "provision.json",
26
26
  "LICENSE",
27
27
  "THIRD-PARTY.md",
package/provision.json CHANGED
@@ -1,51 +1,57 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "name": "reeve",
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "license": "MIT",
6
+ "//": "REEVE 在 ocrc 实例里的装配说明。**没有二进制工件** —— 代理层是插件内的 GATE(纯 JS),不需要任何本地服务(Bifrost 时代已结束)。",
6
7
  "host": {
7
8
  "node": ">=22",
8
- "os": ["linux"],
9
- "arch": ["x64"]
9
+ "os": [
10
+ "linux",
11
+ "win32"
12
+ ],
13
+ "arch": [
14
+ "x64"
15
+ ]
10
16
  },
11
17
  "enginePlugin": {
12
- "spec": "@bd7pil/reeve@0.1.4",
18
+ "spec": "@bd7pil/reeve@0.1.6",
13
19
  "kind": "package",
14
20
  "entry": "~/.ocrc/components/reeve/node_modules/@bd7pil/reeve",
15
21
  "requiresPeer": []
16
22
  },
17
- "artifacts": [
18
- {
19
- "id": "bifrost",
20
- "version": "2.3.0",
21
- "url": "https://downloads.getmaxim.ai/bifrost/v2.3.0/linux/amd64/bifrost-http",
22
- "sha256": "e62a6425218d037e2aea10446f1f4ad1979291902623575a04b7e0ed46886f92",
23
- "size": 134264480,
24
- "dest": "~/.reeve/proxy/bifrost-http",
25
- "mode": "0755"
26
- }
27
- ],
28
23
  "extension": {
29
24
  "apiVersion": 1,
30
25
  "id": "reeve",
31
26
  "zip": "https://github.com/BD7PIL/reeve/releases/download/reeve-latest/reeve.zip",
32
27
  "tag": "reeve-latest",
33
- "capabilities": ["sessions", "filesystem"],
28
+ "capabilities": [
29
+ "sessions",
30
+ "filesystem"
31
+ ],
34
32
  "filesystem": [
35
33
  "~/.reeve-staging/.reeve/**",
36
34
  "~/.reeve/**",
37
35
  "~/.local/share/opencode/reeve/**",
38
- "~/.reeve-staging/.local/share/opencode/reeve/**"
36
+ "~/.reeve-staging/.local/share/opencode/reeve/**",
37
+ "~/.config/opencode/reeve/**",
38
+ "~/.reeve-staging/.config/opencode/reeve/**"
39
39
  ]
40
40
  },
41
- "configSync": ["reeve.jsonc", "agents"],
41
+ "//configSync": "GATE 的 fallback 配置(链/策略)由 ocrc 的 sync 推;reeve.jsonc 与 agents 是插件自身的配置面。",
42
+ "configSync": [
43
+ "reeve.jsonc",
44
+ "agents",
45
+ ".config/opencode/reeve"
46
+ ],
42
47
  "verify": [
43
48
  {
44
- "kind": "file",
45
- "path": "~/.reeve/proxy/bifrost-http",
46
- "mode": "0755"
49
+ "kind": "engine-plugin-loaded",
50
+ "spec": "@bd7pil/reeve"
47
51
  },
48
- { "kind": "engine-plugin-loaded", "spec": "@bd7pil/reeve" },
49
- { "kind": "guest-installed", "id": "reeve" }
52
+ {
53
+ "kind": "guest-installed",
54
+ "id": "reeve"
55
+ }
50
56
  ]
51
57
  }
package/src/constants.js CHANGED
@@ -1,10 +1,24 @@
1
1
  // REEVE 记账层的常量与状态机定义。
2
2
  // 施工图:docs/research/opencode-v2-plugin-api.md
3
3
 
4
+ import { readFileSync } from 'node:fs'
5
+
4
6
  export const PLUGIN_ID = 'reeve'
5
7
 
6
- /** 与 package.json 的 version 保持一致;快照文件里会带上,便于面板判断版本。 */
7
- export const PLUGIN_VERSION = '0.1.4'
8
+ /**
9
+ * 插件版本(快照文件里会带上,便于面板/台账判断"跑的是哪一版")。
10
+ *
11
+ * **从 package.json 读**,不再手抄一个常量:手抄的那种"保持一致"是句祝愿 ——
12
+ * 0.1.5 发布时就漂过(package.json 0.1.5、常量还写 0.1.4,两端快照都报了旧版本)。
13
+ * 读不到就退回 '0.0.0'(只影响一个字符串,别让插件起不来)。
14
+ */
15
+ export const PLUGIN_VERSION = (() => {
16
+ try {
17
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
18
+ } catch {
19
+ return '0.0.0'
20
+ }
21
+ })()
8
22
 
9
23
  /** 工具注册的命名空间。注意:真正决定"模型能否看见"的是 codemode:false(施工图 §3.1)。 */
10
24
  export const TOOL_NAMESPACE = 'reeve'
@@ -0,0 +1,77 @@
1
+ // 错误分类:决定"这一笔该不该换候选"。
2
+ //
3
+ // 语义顺序(复刻自我们 v1 时代那套 fallback 的行为规格,不引入其代码):
4
+ // ① 硬拒绝:abort / context_overflow → **永不换**(换了也是同样结局,还会跟用户的中断打架)
5
+ // ② 语义必换:quota_exceeded / model_not_found / missing_api_key → 不看状态码,直接换
6
+ // ③ 状态码白名单(policy.retry_on_errors)→ 换
7
+ // ④ 显式 isRetryable === true → 需要"状态码安全阀"(白名单 ∪ 5xx ∪ 408/425/429)才接受
8
+ // ⑤ 文本兜底(中英,含 9router 冷却期那种 503+retry-after 的说法)
9
+ //
10
+ // 只做判断,不做 IO——纯函数,测试逐条钉。
11
+
12
+ const NEVER = [/abort/i, /context[\s_-]{0,12}overflow/i, /context\s+length/i, /too\s+many\s+tokens/i, /上下文.{0,6}超/i, /超出.{0,8}上[下]文/i]
13
+ const ALWAYS = [
14
+ /quota[\s_-]*exceeded/i,
15
+ /resource[\s_-]*exhausted/i,
16
+ /out\s+of\s+credits/i,
17
+ /payment\s+required/i,
18
+ /insufficient[\s_-]*(quota|credits?|balance)/i,
19
+ /使用上限|额度不足|余额不足|已耗尽|达到.{0,6}限制/i,
20
+ /model[^\n]{0,24}not[^\n]{0,12}(found|supported)/i,
21
+ /模型.{0,6}(不存在|不支持)/i,
22
+ /missing[\s_-]*api[\s_-]*key/i,
23
+ /api[\s_-]*key[^\n]{0,16}(missing|not\s+set|required)/i,
24
+ ]
25
+ const TEXT_HINTS = [
26
+ /rate[\s_-]*limit/i,
27
+ /too[\s_-]*many[\s_-]*requests/i,
28
+ /quota\s+will\s+reset\s+after/i,
29
+ /all\s+credentials\s+for\s+model/i,
30
+ /service[\s_-]*unavailable/i,
31
+ /overloaded/i,
32
+ /try[\s_-]*again/i,
33
+ /频率限制|请求过于频繁|暂时不可用|服务不可用|请稍后重试|限流/i,
34
+ ]
35
+ const SAFE_EXTRA = Object.freeze([408, 425, 429])
36
+
37
+ const asString = (v) => (typeof v === 'string' ? v : '')
38
+
39
+ /** 状态码提取:`statusCode` → `status` → 嵌套(data/error/cause)→ 从文本里按白名单数字找。 */
40
+ export function statusOf({ status, text, error, policy } = {}) {
41
+ const candidates = [status, error?.statusCode, error?.status, error?.data?.statusCode, error?.data?.status, error?.error?.statusCode, error?.error?.status, error?.cause?.statusCode, error?.cause?.status]
42
+ for (const c of candidates) if (Number.isFinite(Number(c)) && Number(c) >= 100) return Number(c)
43
+ const hay = `${asString(text)} ${asString(error?.message)} ${asString(error?.error?.message)}`
44
+ for (const n of policy?.retry_on_errors ?? []) {
45
+ if (new RegExp(`\\b${n}\\b`).test(hay)) return n
46
+ }
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * @returns {{ retryable: boolean, reason: string, status: number|null }}
52
+ * reason ∈ never:abort|never:context | always:quota|always:model|always:key | status:<n> | explicit | text:<hint> | not-retryable
53
+ */
54
+ export function classifyFailure({ status, text, error, headers, policy } = {}) {
55
+ const hay = `${asString(text)} ${asString(error?.message)} ${asString(error?.name)} ${asString(error?.error?.message)}`
56
+ for (const re of NEVER) if (re.test(hay)) return { retryable: false, reason: re === NEVER[0] ? 'never:abort' : 'never:context', status: statusOf({ status, text, error, policy }) }
57
+ for (const [i, re] of ALWAYS.entries()) if (re.test(hay)) return { retryable: true, reason: ['always:quota', 'always:quota', 'always:quota', 'always:quota', 'always:quota', 'always:quota', 'always:model', 'always:model', 'always:key', 'always:key'][i] ?? 'always', status: statusOf({ status, text, error, policy }) }
58
+
59
+ const code = statusOf({ status, text, error, policy })
60
+ const list = policy?.retry_on_errors ?? []
61
+ if (code !== null && list.includes(code)) return { retryable: true, reason: `status:${code}`, status: code }
62
+
63
+ if (error?.isRetryable === true || error?.data?.isRetryable === true) {
64
+ const safe = code !== null && (list.includes(code) || (code >= 500 && code < 600) || SAFE_EXTRA.includes(code))
65
+ return { retryable: safe, reason: safe ? 'explicit' : 'explicit-unsafe-status', status: code }
66
+ }
67
+
68
+ for (const re of TEXT_HINTS) if (re.test(hay)) return { retryable: true, reason: 'text', status: code }
69
+ // 裸数字:429/503/… 出现在文本里(上游把码写进了消息)
70
+ for (const n of list) if (new RegExp(`\\b${n}\\b`).test(hay)) return { retryable: true, reason: `text:${n}`, status: code }
71
+
72
+ // 9router 形状:冷却期内的 503 + retry-after(有 retry-after 就是"过一会儿再来",值得换)
73
+ const retryAfter = headers?.get?.('retry-after') ?? headers?.['retry-after']
74
+ if (code === 503 && retryAfter) return { retryable: true, reason: 'status:503', status: code }
75
+
76
+ return { retryable: false, reason: 'not-retryable', status: code }
77
+ }
@@ -0,0 +1,172 @@
1
+ // GATE 配置加载与归一化。
2
+ //
3
+ // 配置来源(按序):options.fallbackConfigPath → ~/.config/opencode/reeve-fallback.jsonc → 打包的 default-fallback.json。
4
+ // 规则与仓库其它组件同一条:**坏文件当作"未变更"**(保留上一份好配置、如实报问题),缺文件回落打包默认。
5
+ // jsonc 支持整行 `//` 注释(与引擎侧 opencode.jsonc 同风格)。
6
+ import { readFile, stat } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ /** 打包的兜底配置(无用户配置时用它)。 */
12
+ export const BUNDLED_FALLBACK_PATH = fileURLToPath(new URL('../../default-fallback.json', import.meta.url))
13
+
14
+ /**
15
+ * 机器上的用户配置(三端共享同一份)。
16
+ * 形状说明:`~/.config/opencode/reeve/` 这一个**目录**是三端共享面(开发机上是 junction → 仓内
17
+ * `.config/opencode/reeve/`,对端由 ocrc sync 同步这个目录),配置文件放里面;以后要再加
18
+ * "策略/车道"之类的小配置也进这个目录,别再散落。
19
+ */
20
+ export function defaultFallbackConfigPath({ home = homedir() } = {}) {
21
+ return join(home, '.config', 'opencode', 'reeve', 'fallback.jsonc')
22
+ }
23
+
24
+ export const POLICY_DEFAULTS = Object.freeze({
25
+ retry_on_errors: Object.freeze([401, 402, 403, 429, 500, 503, 529]),
26
+ max_fallback_attempts: 3,
27
+ cooldown_seconds: 60,
28
+ restore_primary_after_cooldown: false,
29
+ first_prompt_watchdog_ms: 90_000,
30
+ provider_retries: 1,
31
+ models_ttl_seconds: 300,
32
+ session_ttl_seconds: 1800,
33
+ })
34
+
35
+ const DEFAULT_PATH = '/v1/chat/completions'
36
+ export const LANES = Object.freeze({ NATIVE: 'native', PASSTHROUGH: 'passthrough' })
37
+
38
+ export function stripLineComments(text) {
39
+ return String(text)
40
+ .split('\n')
41
+ .filter((l) => !l.trim().startsWith('//'))
42
+ .join('\n')
43
+ }
44
+
45
+ const asRecord = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
46
+ const asStrings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.length > 0) : [])
47
+
48
+ /**
49
+ * 归一化 + 校验。**不抛**:把能用的一部分留下,问题如实收集进 `problems`(面板与日志据此报)。
50
+ */
51
+ export function normalizeGateConfig(raw) {
52
+ const problems = []
53
+ const src = asRecord(raw) ?? {}
54
+ const policy = { ...POLICY_DEFAULTS }
55
+ const pSrc = asRecord(src.policy) ?? {}
56
+ for (const key of Object.keys(POLICY_DEFAULTS)) {
57
+ const v = pSrc[key]
58
+ if (v === undefined) continue
59
+ if (typeof v !== typeof POLICY_DEFAULTS[key] && !(Array.isArray(v) && Array.isArray(POLICY_DEFAULTS[key]))) {
60
+ problems.push(`policy.${key} 类型不对(忽略)`)
61
+ continue
62
+ }
63
+ policy[key] = v
64
+ }
65
+ policy.retry_on_errors = (Array.isArray(policy.retry_on_errors) ? policy.retry_on_errors : []).filter((n) => Number.isFinite(n))
66
+
67
+ const providers = {}
68
+ for (const [id, v] of Object.entries(asRecord(src.providers) ?? {})) {
69
+ const p = asRecord(v)
70
+ if (!p) {
71
+ problems.push(`providers.${id} 不是对象(忽略)`)
72
+ continue
73
+ }
74
+ if (typeof p.base_url !== 'string' || p.base_url.length === 0) {
75
+ problems.push(`providers.${id} 缺 base_url(不可达,候选会被跳过)`)
76
+ }
77
+ providers[id] = {
78
+ id,
79
+ base_url: typeof p.base_url === 'string' ? p.base_url.replace(/\/+$/, '') : '',
80
+ path: typeof p.path === 'string' && p.path.length > 0 ? p.path : DEFAULT_PATH,
81
+ auth: asStrings(p.auth).length > 0 ? asStrings(p.auth) : [id],
82
+ key: typeof p.key === 'string' && p.key.length > 0 ? p.key : null,
83
+ models: asStrings(p.models),
84
+ models_fetch: p.models_fetch === true,
85
+ lane: p.lane === LANES.PASSTHROUGH ? LANES.PASSTHROUGH : LANES.NATIVE,
86
+ retries: Number.isFinite(p.retries) ? Number(p.retries) : policy.provider_retries,
87
+ }
88
+ }
89
+
90
+ const chains = {}
91
+ for (const [name, v] of Object.entries(asRecord(src.chains) ?? {})) {
92
+ const targets = asStrings(asRecord(v)?.targets)
93
+ if (targets.length === 0) {
94
+ problems.push(`chains.${name} 没有 targets(忽略)`)
95
+ continue
96
+ }
97
+ chains[name] = { targets }
98
+ }
99
+
100
+ const agents = {}
101
+ for (const [agent, chain] of Object.entries(asRecord(src.agents) ?? {})) {
102
+ if (typeof chain !== 'string' || !chains[chain]) {
103
+ problems.push(`agents.${agent} 指向的链不存在:${String(chain)}(忽略)`)
104
+ continue
105
+ }
106
+ agents[agent] = chain
107
+ }
108
+
109
+ const chainForModel = {}
110
+ for (const [model, chain] of Object.entries(asRecord(src.chain_for_model) ?? {})) {
111
+ if (typeof chain !== 'string' || !chains[chain]) {
112
+ problems.push(`chain_for_model.${model} 指向的链不存在:${String(chain)}(忽略)`)
113
+ continue
114
+ }
115
+ chainForModel[model] = chain
116
+ }
117
+
118
+ const defaultChain = typeof src.default_chain === 'string' && chains[src.default_chain] ? src.default_chain : Object.keys(chains)[0] ?? null
119
+ if (defaultChain === null) problems.push('没有可用的链(default_chain 缺失)')
120
+
121
+ return { schema: 1, policy, providers, chains, agents, chain_for_model: chainForModel, default_chain: defaultChain, problems }
122
+ }
123
+
124
+ /**
125
+ * 读配置:returns `{ config, from, problems, error }`。
126
+ * `raw === null` 表示"这次没读到可用的一层"(调用方保留上一份好配置)。
127
+ */
128
+ export async function loadGateConfig({ path = defaultFallbackConfigPath(), bundledPath = BUNDLED_FALLBACK_PATH, readFileImpl = readFile } = {}) {
129
+ for (const p of [path, bundledPath]) {
130
+ if (!p) continue
131
+ try {
132
+ const text = await readFileImpl(p, 'utf8')
133
+ const parsed = JSON.parse(stripLineComments(text))
134
+ const config = normalizeGateConfig(parsed)
135
+ return { config, from: p, problems: config.problems, error: null }
136
+ } catch (err) {
137
+ // 用户配置读不动 → 试打包默认;打包默认也读不动 → 报错(调用方保留上一份)
138
+ if (p === bundledPath) return { config: null, from: null, problems: [], error: `配置读不动:${String(err).slice(0, 160)}` }
139
+ }
140
+ }
141
+ return { config: null, from: null, problems: [], error: '没有可读的配置(用户配置与打包默认都失败)' }
142
+ }
143
+
144
+ /**
145
+ * 只 stat 的热更探针(与插件盯链文件的同一条纪律:**只 stat**,变了才重读)。
146
+ * 返回 { stop };`changed` 回调拿到新的 `{ config, from, error }`(坏文件由调用方决定保留旧的)。
147
+ */
148
+ export function watchGateConfig({ path = defaultFallbackConfigPath(), bundledPath = BUNDLED_FALLBACK_PATH, intervalMs = 5000, onChange, readFileImpl = readFile, statImpl = stat }) {
149
+ let marker = null
150
+ const tick = async () => {
151
+ let m = null
152
+ try {
153
+ const st = await statImpl(path)
154
+ m = `${Number(st.mtimeMs)}:${Number(st.size)}`
155
+ } catch {
156
+ m = 'missing'
157
+ }
158
+ if (marker === null) {
159
+ marker = m
160
+ return
161
+ }
162
+ if (m === marker) return
163
+ marker = m
164
+ const next = await loadGateConfig({ path, bundledPath, readFileImpl })
165
+ onChange?.(next)
166
+ }
167
+ const timer = setInterval(() => void tick().catch(() => {}), Math.max(200, intervalMs))
168
+ timer.unref?.()
169
+ // 立刻记一次基线:否则"启动后马上改文件"会被第一次 tick 当成基线吞掉(实测踩到)
170
+ void tick().catch(() => {})
171
+ return { stop: () => clearInterval(timer) }
172
+ }