@zhushanwen/pi-provider-live-sync 0.2.0

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 ADDED
@@ -0,0 +1,40 @@
1
+ # @zhushanwen/pi-provider-live-sync
2
+
3
+ 让**运行中**的 pi 进程实时看到 provider / 模型 / 凭据的配置变更。
4
+
5
+ ## 解决的问题
6
+
7
+ pi 的可用模型集合在**进程启动时**冻结:`set_model` 从内存快照 `getAvailableSnapshot()` 解析,
8
+ 而 pi 自身没有文件 watcher、也没有 refresh RPC。于是「会话开着 → 在设置页新增 provider/模型/凭据 →
9
+ 直接切到新模型」会失败并报 `Model not found`(缺凭据与缺模型是同一句文案),只能重开会话。
10
+
11
+ ## 机制
12
+
13
+ - 每 **2 秒**读一次 `<agentDir>/models.json` 与 `<agentDir>/auth.json`(`getAgentDir()` 解析,
14
+ 尊重 `PI_CODING_AGENT_DIR`),按**字节内容**比较;
15
+ - 内容变化(或文件从无到有)→ 调 `ctx.modelRegistry.refresh({ allowNetwork: false })`:
16
+ pi 的 `ModelRuntime.refresh()` 首行重读磁盘配置并重组 provider,`allowNetwork:false` 保证
17
+ **零网络请求**(本地恢复相位,凭据解析/入网前返回);
18
+ - 刷新后读 `ctx.modelRegistry.getError()`:非空即原样落 ERROR 日志(模型配置被引擎拒绝的
19
+ 唯一有效判据——该退化形态下 `refresh().errors` 为空)。
20
+
21
+ ## 语义要点(易误解处)
22
+
23
+ | 情形 | 行为 |
24
+ |------|------|
25
+ | `models.json` **一直不存在**(catalog-only 安装的常态) | 视为**非变化信号**,不阻断 `auth.json` 的比较与刷新 |
26
+ | 文件**曾存在、现在缺失**(被隔离/外部删除) | 更新基线但**抑制刷新**(避免用空配置重建可用集合),只记一行日志 |
27
+ | 读失败(权限等非 ENOENT) | 该文件本拍不比较、基线不动;另一文件照常 |
28
+ | 刷新抛错 | 基线照常推进(同一内容不重复刷新),下一拍继续 |
29
+
30
+ ## 安装
31
+
32
+ 作为 taiji 内置扩展随应用打包(`mandatory-extensions.json` 中 tier=infrastructure)。
33
+ 独立 pi 用户亦可安装:`pi install @zhushanwen/pi-provider-live-sync`。
34
+
35
+ ## 排障
36
+
37
+ - 日志前缀 `[provider-live-sync]`:`config change detected (...)` 表示检测到变更并已刷新;
38
+ `model config rejected by the engine: ...` 表示 pi 拒绝了配置文件(原文含字段路径);
39
+ `config file disappeared: ...` 表示文件被隔离/删除(此拍不刷新,引擎保留上一份快照)。
40
+ - 配置无轮询开关:周期为设计标定值(2s)。
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@zhushanwen/pi-provider-live-sync",
3
+ "version": "0.2.0",
4
+ "description": "Keep the running pi process's model snapshot in sync with on-disk provider/model/credential config changes.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "taiji": {
8
+ "role": "universal"
9
+ },
10
+ "pi": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ],
14
+ "skills": [
15
+ "./skills"
16
+ ]
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "extension",
21
+ "provider",
22
+ "model",
23
+ "refresh"
24
+ ],
25
+ "license": "MIT",
26
+ "files": [
27
+ "src/",
28
+ "index.ts",
29
+ "skills/"
30
+ ],
31
+ "dependencies": {
32
+ "@zhushanwen/pi-extension-logger": "0.6.1"
33
+ },
34
+ "devDependencies": {
35
+ "@vitest/coverage-v8": "^4.1.9",
36
+ "vitest": "^4.1.8"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "^0.84.4"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "npx tsc --noEmit",
43
+ "test": "vitest run"
44
+ }
45
+ }
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: provider-live-sync-ext-config
3
+ description: 使用或排查 @zhushanwen/pi-provider-live-sync(运行中 pi 的模型快照实时同步)时加载。说明它为何存在(pi 快照冻结在 spawn 时刻)、轮询语义(2s / 内容比较 / 按文件独立基线)、四态规则(缺失常态 / 曾存在→缺失抑制 / 读失败跳过 / 变化触发)、零网络保证、错误可见性(getError 全文)、日志与排障、无配置项的原因。触发词:provider 不刷新、新加模型切不过去、Model not found、模型快照、provider-live-sync、配置变更不生效、refresh 模型、扩展刷新。
4
+ ---
5
+
6
+ # provider-live-sync 扩展说明
7
+
8
+ ## 它解决什么
9
+
10
+ pi 的可用模型集合在**进程启动时**冻结:`set_model` 从内存快照 `getAvailableSnapshot()` 解析,
11
+ pi 自身没有文件 watcher、也没有 refresh RPC。因此「会话开着 → 在设置页新增 provider / 模型 / 凭据
12
+ → 直接切到新模型」会失败,报 `Model not found`(缺凭据与缺模型是同一句文案),只能重开会话。
13
+
14
+ 本扩展把「配置变更 → 引擎重读」变成常态路径:每 2 秒比对 `<agentDir>/models.json` 与
15
+ `<agentDir>/auth.json` 的**字节内容**,有变化就调 pi 自己的
16
+ `ctx.modelRegistry.refresh({ allowNetwork: false })`。pi 的 `ModelRuntime.refresh()` 首行会重读磁盘
17
+ 配置并重组 provider,所以新增 / 删除 provider、模型、凭据都会立即对运行中的会话生效。
18
+
19
+ ## 无配置项(有意)
20
+
21
+ 轮询周期 2s 是设计标定值:它是「内容变更 → 生效」的上界,且小于「保存 → 切回会话 → 打开 popover
22
+ → 点选」的人工时延。没有开关、没有阈值、没有排除列表——本扩展**只做一件事**,任何配置面都会扩大
23
+ 「配置错了就不生效」的故障面。需要改周期请改包内常量并同步设计文档。
24
+
25
+ ## 语义要点(排障时先看这张表)
26
+
27
+ | 观察到的情况 | 行为 | 为什么 |
28
+ |---|---|---|
29
+ | `models.json` **一直不存在**(catalog-only 安装的常态) | 视为**非变化信号**,不影响 `auth.json` 的比较 | 缺失是合法常态;若当作异常会整体关停同步能力 |
30
+ | 文件**曾存在、现在缺失**(被隔离 / 外部删除) | 更新基线但**抑制刷新**,且抑制**持续到该文件被写回**;只在跃迁那一拍记一行日志 | refresh 会以「无 override 的空配置」重建可用集合 = 把一次坏写放大成自定义 provider 的真正丢失;抑制期间引擎保留最后一份好快照 |
31
+ | `auth.json` 消失 | 按**普通变化**处理(触发刷新) | 凭据缺失是自愈的(写回即恢复),且不威胁 provider 配置本身 |
32
+ | 读失败(权限等非 ENOENT) | 该文件本拍不比较、基线不动;另一文件照常 | 无法区分「读不到」与「不存在」时,不动基线避免误触发 |
33
+ | 刷新抛错 | 基线照常推进(同一内容不重复刷新),下一拍继续 | 否则同一坏内容会每拍刷新 + 每拍报错(日志风暴) |
34
+ | 半写文件(截断的内容) | 视为内容变化 → 刷新一次;完整内容到达后再刷一次 | 唯一非原子写方是 pi 对 auth.json 的原地写;瞬时收缩下一拍自愈 |
35
+
36
+ **零网络**:`allowNetwork: false` 让 pi 只跑本地恢复相位,在任何凭据解析 / 入网前返回。
37
+
38
+ ## 日志与排障
39
+
40
+ 日志走 `@zhushanwen/pi-extension-logger`(前缀 `[provider-live-sync]`,不写 pi 不捕获的 stderr):
41
+
42
+ - **error 行**(下表除首行外)→ session JSONL 的 `provider-live-sync:log` custom entry(持久化、
43
+ 不进 LLM 上下文,重开会话可 grep)+ `TAIJI_AGENT_DEBUG=1` / `TAIJI_AGENT_EXT_LOG=1` 时落
44
+ `<agentDir>/logs/provider-live-sync-YYYY-MM-DD.log`;
45
+ - **debug 行**(`config change detected`)→ 仅上述文件日志(裸 pi 默认 no-op;taiji 托管环境
46
+ runtime 恒注入 `TAIJI_AGENT_EXT_LOG=1`,INFO 级落盘)。
47
+
48
+ | 日志 | 含义 |
49
+ |---|---|
50
+ | `config change detected (models.json, auth.json) → refreshing model snapshot` | 检测到变更并已刷新 |
51
+ | `model config rejected by the engine (models.json / auth.json): <pi 原文>` | pi 拒绝了配置(含解析失败与 schema 非法的字段路径)。**这是坏配置的唯一机器证据**——该形态下 `refresh()` 返回的 `errors` 为空,只有 `getError()` 有内容 |
52
+ | `config file disappeared: models.json — refresh suppressed until it is written again` | 文件被隔离 / 删除,进入持续抑制窗口 |
53
+ | `refresh finished with errors (aborted=…, providers=N)` | 通用加固记录(网络 / 凭据 / 取消类;**不是**坏配置判据) |
54
+ | `tick failed: …` | 本拍异常(下拍继续,不影响 pi) |
55
+
56
+ 常见问题:
57
+
58
+ - **切换新加的模型仍报 `Model not found`**:先在文件日志看有没有 `config change detected` 行。
59
+ 没有 → 配置写入的不是该会话所用的目录(检查 `PI_CODING_AGENT_DIR` / 数据目录),或裸 pi
60
+ 形态下 debug 日志默认 no-op(用 `TAIJI_AGENT_DEBUG=1` 重启复现);
61
+ 有 → 再看 session JSONL 里 `model config rejected by the engine` 行(那是 pi 的原话)。
62
+ - **日志出现 `Invalid models.json schema: - <字段路径>`**:配置文件里有 pi 不接受的内容
63
+ (如模型缺 `id`、`baseUrl` 为空串)。按字段路径修正后保存,扩展会自动恢复(≤2s)。
64
+ - **`config file disappeared` 之后模型列表变少**:这是抑制窗口内的预期形态(引擎保留最后一份
65
+ 好快照);把 `models.json` 写正后下一拍自动恢复,重启会话同样可恢复。
66
+
67
+ ## 相关
68
+
69
+ - 设计依据:模型切换与运行中 provider 变更的实时可见性(技术设计文档 §3.3 D3/D11/D12)。
70
+ - pi 语义锚点:`ModelRuntime.refresh()` 重读磁盘(`dist/core/model-runtime.js:501-513`)、
71
+ `getError()` 合成(`:305-317`)、pi-ai 本地相位提前返回(`dist/models.js:153-158`);
72
+ 登记于 `docs/pi-semantics.json`,受 C-proc-08 版本门禁约束。
@@ -0,0 +1,329 @@
1
+ /**
2
+ * provider-live-sync 单元测试(设计 §4.2「单元(extension)」全部断言)。
3
+ *
4
+ * 被测面 = 纯函数(`readObservation` / `evaluateSnapshot`),不依赖 pi 运行时:
5
+ * 决策与基线推进是全部语义所在,时钟/timer 不进断言(timer 只做调度)。
6
+ *
7
+ * 覆盖映射(设计行 → 用例):
8
+ * - 内容变化 → refresh;内容相同 → 零 refresh
9
+ * - auth.json 单独变化仍触发(两文件独立比较)
10
+ * - D3 四态:① 持续缺失(catalog-only 常态)+ 另一文件变化仍触发;② 曾存在→缺失 → 不 refresh;
11
+ * ③ 非 ENOENT 读失败只跳过该文件;④ 不存在→存在 → refresh
12
+ * - 基线推进:同一坏内容连续 N 拍 → 决策 refresh 恰 1 次(不重复)
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
15
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
19
+ import providerLiveSync, {
20
+ evaluateSnapshot,
21
+ readObservation,
22
+ POLL_INTERVAL_MS,
23
+ WATCHED_FILES,
24
+ type FileObservation,
25
+ type Snapshot,
26
+ type WatchedFile,
27
+ } from '../index.ts'
28
+
29
+ // tick 层用例:mock pi 宿主模块的 getAgentDir 指向真实 tmp fixture。纯函数用例不经
30
+ // getAgentDir(readObservation 显式传目录),mock 对它们无影响。
31
+ const piMocks = vi.hoisted(() => ({ agentDir: '' }))
32
+
33
+ vi.mock('@earendil-works/pi-coding-agent', () => ({
34
+ getAgentDir: () => piMocks.agentDir,
35
+ }))
36
+
37
+ function makeSnapshot(): Snapshot {
38
+ return { baselines: new Map(), initialized: false, suppressed: new Set() }
39
+ }
40
+
41
+ function obs(entries: Partial<Record<WatchedFile, FileObservation>>): Map<WatchedFile, FileObservation> {
42
+ const m = new Map<WatchedFile, FileObservation>()
43
+ for (const file of WATCHED_FILES) {
44
+ m.set(file, entries[file] ?? { state: 'absent' })
45
+ }
46
+ return m
47
+ }
48
+
49
+ const present = (content: string): FileObservation => ({ state: 'present', content })
50
+
51
+ describe('evaluateSnapshot —— 变更检测(内容比较 + 按文件独立基线)', () => {
52
+ it('首拍(无基线)建立基线并触发 refresh;内容相同 → 后续拍零 refresh', () => {
53
+ const snap = makeSnapshot()
54
+ const first = evaluateSnapshot(snap, obs({ 'models.json': present('{"providers":{}}') }))
55
+ expect(first).toMatchObject({ action: 'refresh', changedFiles: ['models.json'] })
56
+
57
+ const second = evaluateSnapshot(snap, obs({ 'models.json': present('{"providers":{}}') }))
58
+ expect(second).toEqual({ action: 'none', reason: 'unchanged' })
59
+ })
60
+
61
+ it('内容变化 → refresh(且只列变化的文件)', () => {
62
+ const snap = makeSnapshot()
63
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
64
+ const d = evaluateSnapshot(snap, obs({ 'models.json': present('B'), 'auth.json': present('X') }))
65
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['models.json'] })
66
+ })
67
+
68
+ it('auth.json 单独变化(凭据类变更)→ 仍触发 refresh', () => {
69
+ const snap = makeSnapshot()
70
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
71
+ const d = evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('Y') }))
72
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
73
+ })
74
+
75
+ it('半写内容(截断 JSON)也是「内容变化」→ 触发 refresh;下一拍完整内容到达 → 再刷一次', () => {
76
+ const snap = makeSnapshot()
77
+ evaluateSnapshot(snap, obs({ 'auth.json': present('{"kimi":{"apiKey":"sk-1"}}') }))
78
+ const truncated = evaluateSnapshot(snap, obs({ 'auth.json': present('{"kimi":{"apiK') }))
79
+ expect(truncated).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
80
+ const healed = evaluateSnapshot(snap, obs({ 'auth.json': present('{"kimi":{"apiKey":"sk-1"}}') }))
81
+ expect(healed).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
82
+ })
83
+
84
+ it('基线推进与成败无关:同一坏内容连续 N 拍 → refresh 恰 1 次(不重复、无日志风暴)', () => {
85
+ const snap = makeSnapshot()
86
+ let refreshes = 0
87
+ const bad = present('{ not json')
88
+ for (let i = 0; i < 5; i += 1) {
89
+ const d = evaluateSnapshot(snap, obs({ 'models.json': bad }))
90
+ if (d.action === 'refresh') refreshes += 1
91
+ }
92
+ expect(refreshes).toBe(1)
93
+ })
94
+ })
95
+
96
+ describe('evaluateSnapshot —— D3 四态(models.json 缺失是 catalog-only 常态)', () => {
97
+ it('① 持续缺失 + auth.json 变化 → 仍触发 refresh(缺失不阻断另一文件)', () => {
98
+ const snap = makeSnapshot()
99
+ // 首拍:models.json 不存在(catalog-only 安装常态),auth.json 在场
100
+ const first = evaluateSnapshot(snap, obs({ 'auth.json': present('X') }))
101
+ expect(first).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
102
+ // 第二拍:models.json 仍缺失,auth.json 变化 → 仍要刷新(旧「两文件都成功才比较」规则会失效)
103
+ const second = evaluateSnapshot(snap, obs({ 'auth.json': present('Y') }))
104
+ expect(second).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
105
+ })
106
+
107
+ it('② 曾存在 → 缺失 → skip-missing(抑制 refresh,防空配置重建可用集合)', () => {
108
+ const snap = makeSnapshot()
109
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
110
+ const d = evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('X') }))
111
+ expect(d).toEqual({ action: 'skip-missing', missingFiles: ['models.json'] })
112
+ // 持续缺失:抑制保持(每拍仍判定 skip-missing),但调用方按跃迁去重 → 只在第一拍记一行
113
+ // 日志(`missingLogged`),后续拍零输出(稳态无噪声,设计 §3.6 行 2/S9a②)。
114
+ const d2 = evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('X') }))
115
+ expect(d2).toEqual({ action: 'skip-missing', missingFiles: ['models.json'] })
116
+ })
117
+
118
+ it('②变体1:曾存在 → 缺失 + 另一文件同时变化 → 抑制优先(本拍不刷新)', () => {
119
+ const snap = makeSnapshot()
120
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
121
+ const d = evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('Y') }))
122
+ expect(d).toEqual({ action: 'skip-missing', missingFiles: ['models.json'] })
123
+ })
124
+
125
+ it('②变体2(持续抑制):文件仍缺失时,后续其它文件变化也一律抑制(否则空配置会打塌可用集合)', () => {
126
+ const snap = makeSnapshot()
127
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
128
+ evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('X') }))
129
+ // 第二拍:models.json 仍缺失(基线已 absent),auth.json 又变化 → 仍必须抑制
130
+ const d = evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('Z') }))
131
+ expect(d).toEqual({ action: 'skip-missing', missingFiles: ['models.json'] })
132
+ })
133
+
134
+ it('②变体3(恢复):文件被写回 → 解除抑制并触发一次 refresh', () => {
135
+ const snap = makeSnapshot()
136
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
137
+ evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('X') }))
138
+ const d = evaluateSnapshot(snap, obs({ 'models.json': present('A-fixed'), 'auth.json': present('X') }))
139
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['models.json'] })
140
+ // 抑制已解除:后续同内容拍回到「无变化」
141
+ const d2 = evaluateSnapshot(snap, obs({ 'models.json': present('A-fixed'), 'auth.json': present('X') }))
142
+ expect(d2).toEqual({ action: 'none', reason: 'unchanged' })
143
+ })
144
+
145
+ it('②变体4(auth.json 不对称):auth.json 曾存在 → 缺失 → 按普通变化处理(凭据缺失自愈,不抑制)', () => {
146
+ const snap = makeSnapshot()
147
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
148
+ const d = evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': { state: 'absent' } }))
149
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
150
+ // 写回后再次刷新(恢复)
151
+ const d2 = evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
152
+ expect(d2).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
153
+ })
154
+
155
+ it('③ 非 ENOENT 读失败(unreadable)→ 只跳过该文件,另一文件照常比较', () => {
156
+ const snap = makeSnapshot()
157
+ evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('X') }))
158
+ const d = evaluateSnapshot(snap, obs({ 'models.json': { state: 'unreadable' }, 'auth.json': present('Y') }))
159
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['auth.json'] })
160
+ // 该文件基线未被动(下拍读得回来仍是原内容 → 不误报变化)
161
+ const d2 = evaluateSnapshot(snap, obs({ 'models.json': present('A'), 'auth.json': present('Y') }))
162
+ expect(d2).toEqual({ action: 'none', reason: 'unchanged' })
163
+ })
164
+
165
+ it('③变体:两文件都不可读 → 本拍无动作(不刷新、不推进任何基线)', () => {
166
+ const snap = makeSnapshot()
167
+ const d = evaluateSnapshot(snap, obs({ 'models.json': { state: 'unreadable' }, 'auth.json': { state: 'unreadable' } }))
168
+ expect(d).toEqual({ action: 'none', reason: 'all-unreadable' })
169
+ expect(snap.baselines.size).toBe(0)
170
+ })
171
+
172
+ it('④ 不存在 → 存在(首次保存 provider 物化文件)→ refresh', () => {
173
+ const snap = makeSnapshot()
174
+ evaluateSnapshot(snap, obs({ 'models.json': { state: 'absent' }, 'auth.json': present('X') }))
175
+ const d = evaluateSnapshot(snap, obs({ 'models.json': present('{"providers":{"p":{"models":[{"id":"m"}]}}}'), 'auth.json': present('X') }))
176
+ expect(d).toMatchObject({ action: 'refresh', changedFiles: ['models.json'] })
177
+ })
178
+ })
179
+
180
+ describe('readObservation —— 真实文件系统(ENOENT vs 其它错误的区分)', () => {
181
+ let dir: string
182
+ beforeEach(() => {
183
+ dir = mkdtempSync(join(tmpdir(), 'provider-live-sync-'))
184
+ })
185
+ afterEach(() => {
186
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
187
+ })
188
+
189
+ it('文件存在 → present + 原样内容(不解析)', async () => {
190
+ writeFileSync(join(dir, 'models.json'), '{"providers":{}}')
191
+ const o = await readObservation(dir, 'models.json')
192
+ expect(o).toEqual({ state: 'present', content: '{"providers":{}}' })
193
+ })
194
+
195
+ it('文件不存在 → absent(ENOENT 是合法态,不是错误)', async () => {
196
+ expect(await readObservation(dir, 'models.json')).toEqual({ state: 'absent' })
197
+ })
198
+
199
+ it('目录位(EISDIR)等非 ENOENT 故障 → unreadable', async () => {
200
+ mkdirSync(join(dir, 'auth.json'))
201
+ expect(await readObservation(dir, 'auth.json')).toEqual({ state: 'unreadable' })
202
+ })
203
+
204
+ it('无读权限 → unreadable(不异常外泄)', async () => {
205
+ writeFileSync(join(dir, 'auth.json'), '{}')
206
+ chmodSync(join(dir, 'auth.json'), 0o000)
207
+ try {
208
+ const o = await readObservation(dir, 'auth.json')
209
+ // root 用户(CI 容器)可能无视权限位 → 两种结果都合法,断言不抛即可
210
+ expect(['unreadable', 'present']).toContain(o.state)
211
+ } finally {
212
+ chmodSync(join(dir, 'auth.json'), 0o600)
213
+ }
214
+ })
215
+ })
216
+
217
+ /**
218
+ * tick 层(MF-1-1 回归):decision 之外的扩展运行时行为——首基线完成的置位时机与
219
+ * `modelRegistry.refresh` 触发。
220
+ *
221
+ * catalog-only 全新安装标准路径:首拍双 absent(models.json/auth.json 均不存在)→ 用户
222
+ * 首次保存凭据(auth.json 物化)→ **必须** refresh。缺陷形态:initialized 只在 refresh
223
+ * 分支内置位,双 absent 首拍 decision=none 提前 return 不置位,首个真实变化被
224
+ * firstBaseline 吞掉(用户首次配完凭据切模型仍报 Model not found)。
225
+ *
226
+ * 驱动方式:getAgentDir mock 指向真实 tmp fixture(mkdtemp 自建自删);fake pi 只需
227
+ * `on('session_start')` 捕获 ctx(含 refresh/getError spy);fake timers 只替换
228
+ * setTimeout/clearTimeout——tick 链上的 readFile 与 setImmediate 保持真实,I/O 宏任务
229
+ * 逐事件循环轮次自然落地。
230
+ */
231
+ describe('tick 层 —— 首基线置位时机与 refresh 触发', () => {
232
+ let agentDir: string
233
+
234
+ beforeEach(() => {
235
+ agentDir = mkdtempSync(join(tmpdir(), 'provider-live-sync-tick-'))
236
+ piMocks.agentDir = agentDir
237
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
238
+ })
239
+
240
+ afterEach(() => {
241
+ vi.useRealTimers()
242
+ rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
243
+ })
244
+
245
+ function startWithFakeCtx(): { refresh: ReturnType<typeof vi.fn>; appendEntry: ReturnType<typeof vi.fn> } {
246
+ const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() }))
247
+ const getError = vi.fn(() => null)
248
+ const ctx = { modelRegistry: { refresh, getError } }
249
+ const appendEntry = vi.fn()
250
+ const on = vi.fn()
251
+ providerLiveSync({ on, appendEntry } as unknown as ExtensionAPI)
252
+ const handler = on.mock.calls.find((call) => call[0] === 'session_start')?.[1] as
253
+ | ((event: unknown, sessionCtx: unknown) => void)
254
+ | undefined
255
+ expect(handler).toBeTypeOf('function')
256
+ handler({ type: 'session_start' }, ctx)
257
+ return { refresh, appendEntry }
258
+ }
259
+
260
+ /** 逐真实事件循环轮次(setImmediate 未被 fake)轮询等待谓词成立。 */
261
+ async function waitFor(predicate: () => boolean, maxTurns = 5_000): Promise<void> {
262
+ for (let i = 0; i < maxTurns && !predicate(); i += 1) {
263
+ await new Promise<void>((resolve) => setImmediate(resolve))
264
+ }
265
+ }
266
+
267
+ /**
268
+ * 推进一个轮询周期并等本拍 tick **完整落地**。确定性拍屏障:tick 完成后其
269
+ * `.finally(schedule)` 必然注册下一个 fake timer——等 `getTimerCount() >= 1`
270
+ * 即证明本拍 readFile 链(真实线程池 I/O)已结束。固定轮次 flush 不行:线程池
271
+ * 完成回调的到达轮次不定,偶发「tick 晚于下一拍的 writeFileSync 完成 → 新内容
272
+ * 被当首基线」假红。
273
+ */
274
+ async function tickOnce(): Promise<void> {
275
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
276
+ await waitFor(() => vi.getTimerCount() >= 1)
277
+ }
278
+
279
+ it('MF-1-1:首拍双 absent(catalog-only 全新安装)→ auth.json 首次出现 → 必须 refresh', async () => {
280
+ const { refresh } = startWithFakeCtx()
281
+ await tickOnce() // 首拍:双 absent 建基线,decision=none(首拍本就不刷新)
282
+ expect(refresh).not.toHaveBeenCalled()
283
+ // 用户首次保存凭据:auth.json 物化(absent → present)——不得被 firstBaseline 吞掉
284
+ writeFileSync(join(agentDir, 'auth.json'), '{"kimi":{"apiKey":"sk-1"}}')
285
+ await tickOnce()
286
+ expect(refresh).toHaveBeenCalledTimes(1)
287
+ expect(refresh).toHaveBeenCalledWith({ allowNetwork: false })
288
+ })
289
+
290
+ it('对照不变量:首拍双在场建基线不刷新;此后内容变化 → refresh 恰一次', async () => {
291
+ writeFileSync(join(agentDir, 'models.json'), '{"providers":{}}')
292
+ writeFileSync(join(agentDir, 'auth.json'), '{"a":"x"}')
293
+ const { refresh } = startWithFakeCtx()
294
+ await tickOnce() // 首拍 decision=refresh 被 firstBaseline 吞(pi spawn 时已建快照)
295
+ expect(refresh).not.toHaveBeenCalled()
296
+ writeFileSync(join(agentDir, 'auth.json'), '{"a":"y"}')
297
+ await tickOnce()
298
+ expect(refresh).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ /**
302
+ * 日志通道(MF-2-5 迁移回归):error 级日志必须落 pi.appendEntry(session JSONL custom
303
+ * entry,持久化、不依赖 pi 不捕获的 extension stderr)。驱动 `getError()` 非空分支——
304
+ * 引擎拒绝配置是「坏配置的唯一机器证据」(SKILL.md 排障入口),其可见性不能寄望 stderr。
305
+ */
306
+ it('MF-2-5:引擎拒绝配置 → logger.error 落 pi.appendEntry(provider-live-sync:log)', async () => {
307
+ writeFileSync(join(agentDir, 'models.json'), '{"providers":{}}')
308
+ writeFileSync(join(agentDir, 'auth.json'), '{"a":"x"}')
309
+ const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() }))
310
+ const getError = vi.fn(() => 'Invalid models.json schema: - providers')
311
+ const ctx = { modelRegistry: { refresh, getError } }
312
+ const appendEntry = vi.fn()
313
+ const on = vi.fn()
314
+ providerLiveSync({ on, appendEntry } as unknown as ExtensionAPI)
315
+ const handler = on.mock.calls.find((call) => call[0] === 'session_start')?.[1] as
316
+ | ((event: unknown, sessionCtx: unknown) => void)
317
+ | undefined
318
+ handler({ type: 'session_start' }, ctx)
319
+
320
+ await tickOnce() // 首拍建基线(不刷新)
321
+ writeFileSync(join(agentDir, 'auth.json'), '{"a":"y"}')
322
+ await tickOnce() // 本拍 refresh → getError() 非空 → logger.error
323
+
324
+ expect(appendEntry).toHaveBeenCalledWith('provider-live-sync:log', expect.objectContaining({
325
+ level: 'error',
326
+ message: expect.stringContaining('[provider-live-sync] model config rejected by the engine'),
327
+ }))
328
+ })
329
+ })
package/src/index.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * provider-live-sync —— 运行中 pi 进程的**模型快照实时同步**扩展。
3
+ *
4
+ * 问题(根因①):pi 0.84.4 的可用模型集合冻结在 **spawn 时刻**——`set_model` 从
5
+ * `session.modelRuntime.getAvailableSnapshot()` 解析(`dist/modes/rpc/rpc-mode.js:367-375`),
6
+ * 该快照建于 `ModelRuntime.create()` 的 refresh(`dist/core/model-runtime.js:63-100`),
7
+ * 而 pi 自身**没有文件 watcher、没有 refresh RPC**。于是「会话开着 → 在设置页新增 provider/
8
+ * 模型/凭据 → 直接切到新模型」会报 `Model not found`(缺凭据与缺模型同文案),只能重开会话。
9
+ *
10
+ * 解法:本扩展按固定周期读 `<agentDir>/models.json` 与 `<agentDir>/auth.json`,**内容变化**时
11
+ * 调 pi 自己的 `ctx.modelRegistry.refresh({ allowNetwork: false })`——`ModelRuntime.refresh()`
12
+ * 首行即 `ModelConfig.load(modelsPath)` **重读磁盘配置**,随后 `rebuildProviders()` 按新配置重组
13
+ * (`dist/core/model-runtime.js:501-513`),因此新增/删除 provider、模型与凭据都会立即生效;
14
+ * `allowNetwork:false` 走本地恢复相位并在任何凭据解析/入网前返回(pi-ai `dist/models.js:153-158`)
15
+ * ——**零网络请求**。
16
+ *
17
+ * 设计取舍(详见 docs 侧设计文档 §3.3 D3/D11/D12):
18
+ * - **轮询而非 fs.watch**:仓内 `skill-registry` 有 macOS `fs.watch` 触发率 ~40% 前科;轮询不依赖
19
+ * 事件送达,无静默漏报面。代价 = 每进程 2 个小文件 / 2s 的只读 I/O。
20
+ * - **按文件独立「存在性 + 内容」基线**:状态 = 缺失/存在 × 内容字节。① 持续缺失 = 非变化信号
21
+ * (**models.json 本就不存在是 catalog-only 安装的常态**,不能因此关停另一文件的比较);
22
+ * ② 「曾存在 → 缺失」(quarantine / 外部删除窗口)→ 更新基线但**抑制 refresh**(否则会以空配置
23
+ * 重建可用集合,把坏写放大成真正丢失);③ 非 ENOENT 读失败(EACCES 等)→ 该文件本拍不比较、
24
+ * 不覆盖基线(另一文件照常);④ 内容变化 / 「不存在 → 存在」→ refresh。
25
+ * 其中 `models.json` 的「曾存在 → 缺失」抑制是**持续**的(直到文件回归)——否则下一拍另一个文件的
26
+ * 变更仍会触发 refresh 并应用空配置,保护形同虚设;`auth.json` 消失不威胁配置,按普通变化处理
27
+ * (凭据缺失自愈)。
28
+ * - **基线推进与 refresh 成败无关**:刷新抛错也不回退基线,否则同一坏内容会每拍刷新 + 每拍报错。
29
+ * - **不解析文件内容**:纯字节比较,判定权全交 pi(pi 对 JSONC/BOM 的宽容与本扩展无关)。
30
+ * - **错误可见性靠 pi 自己的诊断**:`refresh()` 只返回 `{aborted, errors}`,本退化类
31
+ * (models.json 解析失败 / schema 非法)实测 `errors.size=0`、`aborted=false`——**唯一有效判据是
32
+ * `ctx.modelRegistry.getError()`**(`dist/core/model-runtime.js:305-317` 合成 config error +
33
+ * per-provider composition error)。非空即原样落 ERROR 日志(不解析、不分类、不新增判定层)。
34
+ * - **定时器 unref**:不阻止 pi 进程退出。
35
+ *
36
+ * 可配置项:无(轮询周期 2s 为设计标定值;如需调整请改本包常量并同步设计文档)。
37
+ */
38
+
39
+ import { readFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+
42
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
43
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
44
+ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
45
+
46
+ /** 轮询周期(ms)。设计标定:内容变更到生效的上界 ≈ 2s,小于「保存 → 切回会话 → 点选」的人工时延。 */
47
+ export const POLL_INTERVAL_MS = 2_000;
48
+
49
+ /** 受监视的两个配置文件(pi 的 models.json 授权源 + auth.json 凭据源)。 */
50
+ export const WATCHED_FILES = ["models.json", "auth.json"] as const;
51
+ export type WatchedFile = (typeof WATCHED_FILES)[number];
52
+
53
+ /** 单文件观察状态:读失败(非 ENOENT)与「文件不存在」是两种不同的态,不可混为一谈。 */
54
+ export type FileObservation =
55
+ | { state: "present"; content: string }
56
+ | { state: "absent" }
57
+ | { state: "unreadable" };
58
+
59
+ /** 单文件基线(上一拍成功观察到的态)。 */
60
+ export type FileBaseline = { state: "present"; content: string } | { state: "absent" };
61
+
62
+ export interface Snapshot {
63
+ baselines: Map<WatchedFile, FileBaseline>;
64
+ /**
65
+ * 是否已完成首个**可比**基线拍:两受监视文件各完成一次可比观察(absent 计入、
66
+ * unreadable 不计)。此后首拍只建基线、不触发 refresh(pi spawn 时已按磁盘建过快照)。
67
+ * 置位与当拍 decision 无关——双 absent 首拍(catalog-only 常态)也构成完整基线。
68
+ */
69
+ initialized: boolean;
70
+ /**
71
+ * **曾存在 → 缺失且尚未恢复**的文件(quarantine / 外部删除窗口)。
72
+ *
73
+ * 抑制是**持续**的(不是一拍):只要 `models.json` 处于这个集合里,任何 refresh 都会重读磁盘、
74
+ * 以「无 override 的空配置」重建可用集合——把一次坏写放大成用户自定义 provider 的**真正丢失**
75
+ * (设计 D3②/§3.6「『曾存在 → 缺失』跃迁抑制 refresh」)。恢复 = 该文件重新出现(内容变化)
76
+ * 或会话重启(spawn 期重读)。
77
+ *
78
+ * 只对 `models.json` 生效:`auth.json` 消失不威胁配置本身(凭据缺失是自愈的——文件写回后
79
+ * 下一拍 refresh 即恢复),按普通内容变化处理,避免「凭据变更被静默丢弃」。
80
+ */
81
+ suppressed: Set<WatchedFile>;
82
+ }
83
+
84
+ export type SyncDecision =
85
+ | { action: "none"; reason: "no-baseline" | "unchanged" | "all-unreadable" }
86
+ | { action: "refresh"; changedFiles: WatchedFile[] }
87
+ | { action: "skip-missing"; missingFiles: WatchedFile[] };
88
+
89
+ /** 读单个受监视文件:ENOENT → absent;其它错误 → unreadable(含权限、I/O 故障)。 */
90
+ export async function readObservation(agentDir: string, file: WatchedFile): Promise<FileObservation> {
91
+ try {
92
+ const content = await readFile(join(agentDir, file), { encoding: "utf8" });
93
+ return { state: "present", content };
94
+ } catch (err) {
95
+ const code = (err as NodeJS.ErrnoException | null)?.code;
96
+ if (code === "ENOENT") return { state: "absent" };
97
+ return { state: "unreadable" };
98
+ }
99
+ }
100
+
101
+ /** 单文件本拍归类:changed(触发 refresh)/ missing(models.json 抑制窗口)/ stable(无变化)。 */
102
+ type FileChangeKind = "changed" | "missing" | "stable";
103
+
104
+ /**
105
+ * 单文件比对 + 基线推进(规则 2-4 的 per-file 分支;返回该文件的变化归类)。
106
+ * present:先摘抑制集合(文件回归即变化),再比内容,基线推进为 present;
107
+ * absent:按 absentChangeKind 归类,基线推进为 absent。
108
+ */
109
+ function classifyFileChange(
110
+ snapshot: Snapshot,
111
+ file: WatchedFile,
112
+ obs: FileObservation,
113
+ ): FileChangeKind {
114
+ const prev = snapshot.baselines.get(file);
115
+
116
+ if (obs.state === "present") {
117
+ // 文件回来了(隔离副本被写回 / 用户重新保存)→ 视为变化,恢复正常刷新。
118
+ const recovered = snapshot.suppressed.delete(file);
119
+ const changed = recovered || !prev || prev.state !== "present" || prev.content !== obs.content;
120
+ snapshot.baselines.set(file, { state: "present", content: obs.content });
121
+ return changed ? "changed" : "stable";
122
+ }
123
+
124
+ const kind = absentChangeKind(snapshot, file, prev);
125
+ snapshot.baselines.set(file, { state: "absent" });
126
+ return kind;
127
+ }
128
+
129
+ /** absent 观察的归类(规则 3 + 抑制持续语义;models.json 路径有入抑制集合的副作用)。 */
130
+ function absentChangeKind(
131
+ snapshot: Snapshot,
132
+ file: WatchedFile,
133
+ prev: FileBaseline | undefined,
134
+ ): FileChangeKind {
135
+ if (prev?.state !== "present") {
136
+ // 仍在抑制窗口内(持续缺失):保持抑制,逐拍静默(只在跃迁那一拍有日志);
137
+ // 从未出现(catalog-only 常态)= 非变化信号。
138
+ return snapshot.suppressed.has(file) ? "missing" : "stable";
139
+ }
140
+ if (file === "models.json") {
141
+ // 配置源消失:抑制**整拍**刷新(refresh 会以空配置重建可用集合);抑制持续到文件回归。
142
+ snapshot.suppressed.add(file);
143
+ return "missing";
144
+ }
145
+ // auth.json 消失:不威胁 provider 配置,按普通变化处理(凭据缺失自愈——写回即恢复)。
146
+ return "changed";
147
+ }
148
+
149
+ /** 本拍决策(优先级:全不可比 → models.json 抑制 → 无变化 → 仅缺失 → refresh)。 */
150
+ function decideAction(
151
+ snapshot: Snapshot,
152
+ changedFiles: WatchedFile[],
153
+ missingFiles: WatchedFile[],
154
+ comparable: number,
155
+ ): SyncDecision {
156
+ if (comparable === 0) return { action: "none", reason: "all-unreadable" };
157
+ // 抑制优先于一切:配置源缺失窗口内绝不 refresh(宁可让引擎保留最后一份好快照)。
158
+ if (snapshot.suppressed.has("models.json")) return { action: "skip-missing", missingFiles: ["models.json"] };
159
+ if (changedFiles.length === 0 && missingFiles.length === 0) {
160
+ return { action: "none", reason: "unchanged" };
161
+ }
162
+ if (changedFiles.length === 0) return { action: "skip-missing", missingFiles };
163
+ return { action: "refresh", changedFiles };
164
+ }
165
+
166
+ /**
167
+ * 对比观察与基线,产出本拍决策**并就地推进基线**(按文件独立)。
168
+ *
169
+ * 规则(与设计 D3 逐条对应,顺序即优先级):
170
+ * 1. `unreadable` → 该文件不参与比较、基线不动(另一文件照常);
171
+ * 2. 与基线同态(present 内容相同 / 双双 absent)→ 变化集不含该文件;
172
+ * 3. `present → absent`(曾存在、现在缺失)→ 记入 missing(调用方抑制 refresh 且只记一行);
173
+ * 4. `absent → present` 或 present 内容变化 → 记入 changed(触发 refresh);
174
+ * 5. 基线推进发生在**决策产出时**,与 refresh 成败无关。
175
+ */
176
+ export function evaluateSnapshot(
177
+ snapshot: Snapshot,
178
+ observations: Map<WatchedFile, FileObservation>,
179
+ ): SyncDecision {
180
+ const changedFiles: WatchedFile[] = [];
181
+ const missingFiles: WatchedFile[] = [];
182
+ let comparable = 0;
183
+
184
+ for (const file of WATCHED_FILES) {
185
+ const obs = observations.get(file);
186
+ if (!obs || obs.state === "unreadable") continue;
187
+ comparable += 1;
188
+ const kind = classifyFileChange(snapshot, file, obs);
189
+ if (kind === "changed") changedFiles.push(file);
190
+ else if (kind === "missing") missingFiles.push(file);
191
+ }
192
+
193
+ return decideAction(snapshot, changedFiles, missingFiles, comparable);
194
+ }
195
+
196
+ /**
197
+ * 本拍完成可比观察的文件数(absent 计入、unreadable / 缺观察不计)——首基线完成判定:
198
+ * 与当拍 decision 解耦,双 absent 首拍(catalog-only 常态)也构成完整基线。
199
+ */
200
+ function comparableCount(observations: Map<WatchedFile, FileObservation>): number {
201
+ let count = 0;
202
+ for (const file of WATCHED_FILES) {
203
+ const obs = observations.get(file);
204
+ if (obs && obs.state !== "unreadable") count += 1;
205
+ }
206
+ return count;
207
+ }
208
+
209
+ /**
210
+ * 日志出口 = @zhushanwen/pi-extension-logger(logging-conventions.md 三层通道分类)。
211
+ * pi 自身不捕获 extension 的 stderr——`process.stderr.write` 直写在独立 pi 用户形态下
212
+ * 不落任何文件(taiji 托管形态的落盘链也只是 runtime rpc-client 转发),故不走 stderr:
213
+ * - error(config 消失 / 引擎拒绝配置 / refresh 报错 / tick 异常)→ pi.appendEntry 持久化
214
+ * (session JSONL custom entry `provider-live-sync:log`,不进 LLM 上下文)+
215
+ * `TAIJI_AGENT_DEBUG=1` / `TAIJI_AGENT_EXT_LOG=1` 时文件日志(`<agentDir>/logs/`);
216
+ * - debug(变更检测的正常观察 `config change detected`)→ 仅文件日志,默认 no-op
217
+ * (taiji 托管环境 runtime spawn 恒注入 `TAIJI_AGENT_EXT_LOG=1`,INFO 级落盘)。
218
+ * 消息前缀 `[provider-live-sync]` 由 logger 按 extName 自动补(getLogger 名即前缀唯一来源)。
219
+ */
220
+ const logger = getLogger("provider-live-sync");
221
+
222
+ export default function providerLiveSync(pi: ExtensionAPI): void {
223
+ // 最早期注入 pi handle——上方 logger 的 error 通道(appendEntry)由此生效(session-reader 同款)。
224
+ setPiHandle(pi);
225
+ const snapshot: Snapshot = { baselines: new Map(), initialized: false, suppressed: new Set() };
226
+ /** ctx 每次 session_start 重新捕获(pi 可能换实例;未就绪时本拍跳过且不推进基线)。 */
227
+ let ctx: ExtensionContext | null = null;
228
+ let timer: NodeJS.Timeout | null = null;
229
+ /** 「曾存在 → 缺失」状态跃迁去重:只在跃迁那一拍记日志,持续缺失零输出。 */
230
+ const missingLogged = new Set<WatchedFile>();
231
+
232
+ pi.on("session_start", (_event, sessionCtx) => {
233
+ ctx = sessionCtx;
234
+ });
235
+
236
+ const tick = async (): Promise<void> => {
237
+ const currentCtx = ctx;
238
+ if (!currentCtx) return; // 会话未就绪:本拍跳过且不推进基线(下拍重试,无 dirty 状态机)
239
+ try {
240
+ const agentDir = getAgentDir();
241
+ const observations = new Map<WatchedFile, FileObservation>();
242
+ for (const file of WATCHED_FILES) {
243
+ observations.set(file, await readObservation(agentDir, file));
244
+ }
245
+ const decision = evaluateSnapshot(snapshot, observations);
246
+ // 首基线完成判定与 refresh 分支解耦:本拍两受监视文件完成可比观察(absent 计入、
247
+ // unreadable 不计)即置位。若只在 refresh 分支置位,双 absent 首拍(catalog-only
248
+ // 全新安装常态)decision=none 永不置位,下一拍 auth.json 首次出现会被 firstBaseline
249
+ // 吞掉——用户首次配完凭据切模型仍报 Model not found。
250
+ const firstBaseline = !snapshot.initialized;
251
+ if (comparableCount(observations) === WATCHED_FILES.length) snapshot.initialized = true;
252
+ if (decision.action === "skip-missing") {
253
+ for (const file of decision.missingFiles) {
254
+ if (!missingLogged.has(file)) {
255
+ missingLogged.add(file);
256
+ logger.error(
257
+ `config file disappeared: ${file} — refresh suppressed until it is written again `
258
+ + "(rebuilding from an empty config would drop the user's custom providers from the running "
259
+ + "engine; it keeps its last good snapshot meanwhile; restarting the session also recovers).",
260
+ );
261
+ }
262
+ }
263
+ return;
264
+ }
265
+ for (const file of WATCHED_FILES) missingLogged.delete(file);
266
+ if (decision.action !== "refresh") return;
267
+ // 首个基线拍不刷新(进程启动时 pi 已按磁盘配置建过快照,重复刷新纯浪费)。
268
+ if (firstBaseline) return;
269
+
270
+ logger.debug(`config change detected (${decision.changedFiles.join(", ")}) → refreshing model snapshot`);
271
+ const result = await currentCtx.modelRegistry.refresh({ allowNetwork: false });
272
+ // 有效判据 = getError() 全文(本退化类 errors.size/aborted 恒为空/假,见模块头注释)。
273
+ const error = currentCtx.modelRegistry.getError();
274
+ if (error) {
275
+ logger.error(`model config rejected by the engine (models.json / auth.json): ${error}`);
276
+ } else if (result.errors.size > 0 || result.aborted) {
277
+ logger.error(`refresh finished with errors (aborted=${String(result.aborted)}, providers=${result.errors.size})`);
278
+ }
279
+ } catch (err) {
280
+ // 轮询绝不自杀:本拍失败下一拍继续(内容未变则基线已推进 → 不会重复刷新同一内容)。
281
+ const message = err instanceof Error ? err.message : String(err);
282
+ logger.error(`tick failed: ${message}`);
283
+ }
284
+ };
285
+
286
+ const schedule = (): void => {
287
+ timer = setTimeout(() => {
288
+ void tick().finally(schedule);
289
+ }, POLL_INTERVAL_MS);
290
+ timer.unref();
291
+ };
292
+
293
+ // 首拍只建基线(不 refresh);此后每拍比较。(pi 无 extension 生命周期 shutdown 钩子,
294
+ // 进程退出时 unref 定时器自然消失。)
295
+ schedule();
296
+ }