Ver código fonte

fix(web): address settings document review

Yichen Jiang 2 meses atrás
pai
commit
4f717f2da7
42 arquivos alterados com 217 adições e 113 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
  4. 1 0
      apps/web/tests/snapshots/models-settings/configured.expected.md
  5. 1 0
      apps/web/tests/snapshots/models-settings/empty.expected.md
  6. 2 1
      packages/client/connection/src/client/fixture.ts
  7. 1 1
      packages/client/connection/tests/fake-api.ts
  8. 1 1
      packages/client/runtime/tests/fake-api.ts
  9. 2 1
      packages/client/ui-models/tests/components.spec.tsx
  10. 4 1
      packages/client/ui-models/tests/store.spec.ts
  11. 1 1
      packages/client/ui-permission/tests/browser-plugin.spec.ts
  12. 5 5
      packages/client/ui-permission/tests/permission-row.spec.tsx
  13. 8 6
      packages/client/ui-permission/tests/settings-store.spec.ts
  14. 2 2
      packages/client/ui-settings-general/README.i18n.yaml
  15. 1 1
      packages/client/ui-settings-general/README.md
  16. 1 1
      packages/client/ui-settings-general/README.zh.md
  17. 3 3
      packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx
  18. 6 3
      packages/client/ui-settings-general/src/client/index.ts
  19. 12 3
      packages/client/ui-settings-general/src/client/settings-document-store.ts
  20. 19 2
      packages/client/ui-settings-general/tests/apply.spec.ts
  21. 23 8
      packages/client/ui-settings-general/tests/components.spec.tsx
  22. 7 7
      packages/client/ui-settings-general/tests/settings-document-store.spec.ts
  23. 1 0
      packages/client/ui-settings-general/tests/welcome-notice.spec.tsx
  24. 9 6
      packages/client/ui-settings-general/tests/welcome-store.spec.ts
  25. 2 2
      packages/host/apiproxy/README.i18n.yaml
  26. 0 0
      packages/host/apiproxy/README.md
  27. 0 0
      packages/host/apiproxy/README.zh.md
  28. 1 1
      packages/host/apiproxy/src/api-proxy.ts
  29. 1 1
      packages/host/apiproxy/src/api/settings.schema.ts
  30. 8 9
      packages/host/apiproxy/src/api/settings.ts
  31. 31 2
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  32. 3 3
      packages/host/apiproxy/tests/client-handler.spec.ts
  33. 1 1
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  34. 10 0
      packages/host/apiproxy/tests/native-path-opener.spec.ts
  35. 2 2
      packages/settings/settings-local/README.i18n.yaml
  36. 2 2
      packages/settings/settings-local/README.md
  37. 2 2
      packages/settings/settings-local/README.zh.md
  38. 28 26
      packages/settings/settings-local/src/index.ts
  39. 8 1
      packages/settings/settings-local/tests/lock-race.spec.ts
  40. 2 2
      packages/settings/settings/README.i18n.yaml
  41. 1 1
      packages/settings/settings/README.md
  42. 1 1
      packages/settings/settings/README.zh.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
-2026-07-30-web-config-plane.md: 399684c782db86213ac7a0944e6e0bfe19145cea
-2026-07-30-web-config-plane.zh.md: de4be026d7431e058e9ae0a6e48bb0a4aba769a7
+2026-07-30-web-config-plane.md: 5d8dbf2b6c71246172ff32cf25ed3829f9e2fc2a
+2026-07-30-web-config-plane.zh.md: 7014ef7cf295cb7d0980529b88bd752c26544fac

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md

@@ -16,7 +16,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
 
 **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
 
-**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries the path as availability metadata beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms a file, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem-target parameter; remote pages make no privileged settings read for this action.
+**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
 
 **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md

@@ -16,7 +16,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
 
 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
 
-**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁把该路径作为可用性元数据携带。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认存在文件后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标参数;远程页面不会为这项操作发起特权 settings 读取。
+**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
 
 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
 

+ 1 - 0
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -7,6 +7,7 @@
     - button "模型":
       - img
       - text: 模型
+  - button "打开配置文件"
   - button "关闭":
     - img
     - text: 关闭

+ 1 - 0
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -7,6 +7,7 @@
     - button "模型":
       - img
       - text: 模型
+  - button "打开配置文件"
   - button "关闭":
     - img
     - text: 关闭

+ 2 - 1
packages/client/connection/src/client/fixture.ts

@@ -2390,7 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
       // editor; real schema-driven forms ride the HTTP transport.
       describe: request => ok(request, {
         writable: true,
-        documentPath: `${FIXTURE_HOME}/settings.yaml`,
+        hasDocument: true,
         namespaces: [{
           ns: 'llm-deepseek',
           schema: {},
@@ -2400,6 +2400,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
           revision: 0,
         }],
       }),
+      // Native opens are deterministic no-op successes in this fixture, as is host.openPath.
       openDocument: request => ok(request, { opened: true as const }),
       update: request => err(request, {
         code: 'settings-rejected',

+ 1 - 1
packages/client/connection/tests/fake-api.ts

@@ -181,7 +181,7 @@ export class FakeApiClient implements IApiClient {
   }
 
   readonly settings: IApiClient['settings'] = {
-    describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
+    describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
     openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
     update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
     replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

+ 1 - 1
packages/client/runtime/tests/fake-api.ts

@@ -216,7 +216,7 @@ export class FakeApiClient implements IApiClient {
   }
 
   readonly settings: IApiClient['settings'] = {
-    describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
+    describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
     openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
     update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
     replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

+ 2 - 1
packages/client/ui-models/tests/components.spec.tsx

@@ -104,7 +104,7 @@ function scriptedFace(overrides: {
       models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
     },
     settings: {
-      describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
+      describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
       update,
       replace,
       mutate,
@@ -541,6 +541,7 @@ describe('ModelsSection', () => {
     const { face } = await mountSection()
     face.settings.describe.mockImplementation(() => Promise.resolve(ok({
       writable: false,
+      hasDocument: false,
       namespaces: wireNamespaces(),
     })))
     const controller = new ModelsSettingsStore(face as unknown as WireFace)

+ 4 - 1
packages/client/ui-models/tests/store.spec.ts

@@ -51,7 +51,7 @@ function api(overrides: {
       models: () => Promise.resolve(ok({ groups: [], failures: [] })),
     },
     settings: {
-      describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
+      describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
       update: () => Promise.resolve(fail('unused')),
       replace: () => Promise.resolve(fail('unused')),
     },
@@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => {
     const { face } = api({
       describeSettings: () => Promise.resolve(ok({
         writable: true,
+        hasDocument: false,
         namespaces: [{
           ...NAMESPACES[0],
           secrets: [
@@ -195,6 +196,7 @@ describe('edge joins', () => {
     const { face } = api({
       describeSettings: () => Promise.resolve(ok({
         writable: true,
+        hasDocument: false,
         namespaces: [{
           ns: 'llm-pi-ai',
           schema: {},
@@ -221,6 +223,7 @@ describe('edge joins', () => {
     const { face, seenRefs } = api({
       describeSettings: () => Promise.resolve(ok({
         writable: true,
+        hasDocument: false,
         namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
       })),
       providers: () => Promise.resolve(ok({

+ 1 - 1
packages/client/ui-permission/tests/browser-plugin.spec.ts

@@ -48,7 +48,7 @@ async function bench() {
       settings: {
         describe: () => Promise.resolve({
           rpcId: 'describe',
-          result: { ok: true as const, value: { writable: true, namespaces: [] } },
+          result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
         }),
         mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
       },

+ 5 - 5
packages/client/ui-permission/tests/permission-row.spec.tsx

@@ -60,7 +60,7 @@ describe('PermissionRow', () => {
     const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
     const controller = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
+        describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
         mutate,
       } as never,
     })
@@ -87,7 +87,7 @@ describe('PermissionRow', () => {
     const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
     const controller = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
+        describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
         mutate,
       } as never,
     })
@@ -111,7 +111,7 @@ describe('PermissionRow', () => {
   it('hides an unavailable namespace and disables a read-only provider', async () => {
     const absent = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
+        describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
         mutate: vi.fn(),
       } as never,
     })
@@ -121,7 +121,7 @@ describe('PermissionRow', () => {
 
     const readonly = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
+        describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
         mutate: vi.fn(),
       } as never,
     })
@@ -148,7 +148,7 @@ describe('PermissionRow', () => {
     })
     mount(controller)
     expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
-    describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
+    describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
     const button = await screen.findByRole('button', { name: 'Read Only' })
     fireEvent.click(button)
     fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))

+ 8 - 6
packages/client/ui-permission/tests/settings-store.spec.ts

@@ -88,6 +88,7 @@ describe('permission settings store', () => {
   it('loads and writes defaultPreset with optimistic concurrency', async () => {
     const describe = vi.fn(() => Promise.resolve(ok({
       writable: true,
+      hasDocument: false,
       namespaces: [view('read-only', 4)],
     })))
     const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
@@ -115,7 +116,7 @@ describe('permission settings store', () => {
   })
 
   it('hides the row when the namespace is absent and contains write failures', async () => {
-    const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
+    const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
     const controller = new PermissionSettingsController({
       settings: { describe, mutate: vi.fn() } as never,
     })
@@ -124,7 +125,7 @@ describe('permission settings store', () => {
 
     const failing = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
+        describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
         mutate: () => Promise.resolve({
           rpcId: 'test',
           result: {
@@ -146,14 +147,14 @@ describe('permission settings store', () => {
     }>>>()
     const describe = vi.fn()
       .mockImplementationOnce(() => first.promise)
-      .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
+      .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
     const mutate = vi.fn()
     const controller = new PermissionSettingsController({
       settings: { describe, mutate } as never,
     })
     const stale = controller.load()
     await controller.load()
-    first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
+    first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
     await stale
     expect(controller.store.getSnapshot()).toMatchObject({
       currentValue: 'read-only',
@@ -200,7 +201,7 @@ describe('permission settings store', () => {
     expect(describe).not.toHaveBeenCalled()
     const loading = idle.load()
     idle.dispose()
-    read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
+    read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
     await loading
     expect(idle.store.getSnapshot().status).toBe('loading')
 
@@ -220,6 +221,7 @@ describe('permission settings store', () => {
     const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
     const activeDescribe = vi.fn(() => Promise.resolve(ok({
       writable: true,
+      hasDocument: false,
       namespaces: [view('read-only')],
     })))
     const active = new PermissionSettingsController({
@@ -240,7 +242,7 @@ describe('permission settings store', () => {
     const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
     const disposedWrite = new PermissionSettingsController({
       settings: {
-        describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
+        describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
         mutate: () => rejectedMutation.promise,
       } as never,
     })

+ 2 - 2
packages/client/ui-settings-general/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
-README.md: d9aeaa37b3953f586821871118b009abd72b896d
-README.zh.md: 96666e2d4d2087ca9579a1bcdd6a00ccfdaaa90d
+README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
+README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f

+ 1 - 1
packages/client/ui-settings-general/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
 
-A loopback browser loads the provider's optional `documentPath` through `settings.describe` and renders **Open configuration file** only when the Host confirms one local file. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Remote browsers never register the action and never issue the privileged settings read.
+A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
 
 `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
 

+ 1 - 1
packages/client/ui-settings-general/README.zh.md

@@ -4,7 +4,7 @@
 
 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
 
-回环浏览器通过 `settings.describe` 加载提供方可选的 `documentPath`,且只有在 Host 确认存在一个本地文件时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
+回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
 
 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
 

+ 3 - 3
packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx

@@ -21,7 +21,7 @@ export type SettingsDocumentActionProps =
   PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected
 
 /**
- * Render the open-document action only after Host metadata confirms a local file.
+ * Render the open-document action only after Host metadata confirms document availability.
  * @param props - header owner props, localized copy, and injected document state.
  * @returns the action, or null while unavailable or unresolved.
  */
@@ -29,8 +29,8 @@ export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsD
   const state = useSnapshot(snapshot => snapshot)
 
   useEffect(() => {
-    if (state.status === 'idle') void controller.load()
-  }, [controller, state.status])
+    void controller.load()
+  }, [controller])
 
   if (state.status !== 'ready') return null
 

+ 6 - 3
packages/client/ui-settings-general/src/client/index.ts

@@ -17,7 +17,7 @@ import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
 import { GeneralSection } from './GeneralSection.tsx'
 import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
 import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
-import { SettingsDocumentStore } from './settings-document-store.ts'
+import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
 import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
 import { WelcomeNotice } from './WelcomeNotice.tsx'
 import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
@@ -90,10 +90,13 @@ export function apply(ctx: ClientContext): void {
     }
     const disposers = [
       ctx.on('settings/changed', refresh),
-      ctx.on('connection/reset', () => { refresh() }),
+      ctx.on('connection/reset', () => {
+        refresh()
+        refreshDocumentIfLoaded(documentController)
+      }),
     ]
     return () => { for (const dispose of disposers) dispose() }
-  }, 'ui-settings-general: welcome invalidations')
+  }, 'ui-settings-general: metadata invalidations')
   ctx.effect(() => {
     const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
       ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))

+ 12 - 3
packages/client/ui-settings-general/src/client/settings-document-store.ts

@@ -5,7 +5,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
 
 /** Browser state of the Host-owned settings document. */
 export interface SettingsDocumentState {
-  /** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */
+  /** Metadata-loading phase; unavailable means the provider has no local document or the read failed. */
   status: 'idle' | 'loading' | 'ready' | 'unavailable'
   /** Whether one native-open request is in flight. */
   opening: boolean
@@ -32,7 +32,7 @@ export class SettingsDocumentStore {
   constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
 
   /**
-   * Load the current provider's optional local document path.
+   * Load whether the current provider owns a local document.
    * @returns after the latest metadata response updates the store.
    */
   async load(): Promise<void> {
@@ -52,7 +52,7 @@ export class SettingsDocumentStore {
         return
       }
       this.store.update((state) => {
-        state.status = result.value.documentPath === undefined ? 'unavailable' : 'ready'
+        state.status = result.value.hasDocument ? 'ready' : 'unavailable'
         state.error = null
       })
     } catch (error) {
@@ -85,3 +85,12 @@ export class SettingsDocumentStore {
     }
   }
 }
+
+/**
+ * Refresh document availability after reconnect only when a surface has already requested it.
+ * @param controller - optional loopback document state owner.
+ */
+export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
+  if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
+  void controller.load()
+}

+ 19 - 2
packages/client/ui-settings-general/tests/apply.spec.ts

@@ -39,7 +39,7 @@ async function bench(isLoopback = true) {
       ok: true as const,
       value: {
         writable: true,
-        documentPath: '/tmp/test-settings.yaml',
+        hasDocument: true,
         namespaces: [{
           ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
           schema: {},
@@ -175,10 +175,25 @@ describe('ui-settings-general apply', () => {
     await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
   })
 
+  it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries('settings.action')[0]!
+    const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
+    b.ctx.emit('connection/reset')
+    expect(b.settingsDescribe).not.toHaveBeenCalled()
+    await controller.load()
+    expect(b.settingsDescribe).toHaveBeenCalledOnce()
+    b.ctx.emit('connection/reset')
+    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
+  })
+
   it('keeps remote welcome acknowledgement process-local', async () => {
     const b = await bench(false)
     declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
     const entry = b.slots.entries('settings.onboarding')[0]!
     const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
 
@@ -187,6 +202,8 @@ describe('ui-settings-general apply', () => {
     expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
     expect(b.settingsDescribe).not.toHaveBeenCalled()
     expect(b.slots.entries('settings.action')).toEqual([])
+    await fiber.dispose()
+    for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
   })
 
   it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {

+ 23 - 8
packages/client/ui-settings-general/tests/components.spec.tsx

@@ -70,7 +70,7 @@ describe('SettingsDocumentAction', () => {
           rpcId: 'document-action' as never,
           result: {
             ok: true as const,
-            value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] },
+            value: { writable: true, hasDocument: true, namespaces: [] },
           },
         })),
         openDocument,
@@ -87,17 +87,23 @@ describe('SettingsDocumentAction', () => {
     await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
   })
 
-  it('stays absent when the provider has no local document', async () => {
+  it('stays absent without a document and retries availability after remount', async () => {
+    const describe = vi.fn()
+      .mockResolvedValueOnce({
+        rpcId: 'document-action-absent' as never,
+        result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
+      })
+      .mockResolvedValueOnce({
+        rpcId: 'document-action-ready' as never,
+        result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+      })
     const controller = new SettingsDocumentStore({
       settings: {
-        describe: vi.fn(() => Promise.resolve({
-          rpcId: 'document-action' as never,
-          result: { ok: true as const, value: { writable: true, namespaces: [] } },
-        })),
+        describe,
         openDocument: vi.fn(),
       },
     } as never)
-    render(<SettingsDocumentAction
+    const first = render(<SettingsDocumentAction
       {...kit}
       t={t}
       controller={controller}
@@ -105,6 +111,15 @@ describe('SettingsDocumentAction', () => {
     />)
     await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
     expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
+    first.unmount()
+    render(<SettingsDocumentAction
+      {...kit}
+      t={t}
+      controller={controller}
+      useSnapshot={bindSnapshotSelector(controller.store)}
+    />)
+    expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
+    expect(describe).toHaveBeenCalledTimes(2)
   })
 
   it('keeps the action available and reports a native-open failure', async () => {
@@ -114,7 +129,7 @@ describe('SettingsDocumentAction', () => {
           rpcId: 'document-action' as never,
           result: {
             ok: true as const,
-            value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] },
+            value: { writable: true, hasDocument: true, namespaces: [] },
           },
         })),
         openDocument: vi.fn(() => Promise.resolve({

+ 7 - 7
packages/client/ui-settings-general/tests/settings-document-store.spec.ts

@@ -2,16 +2,16 @@ import { describe, expect, it, vi } from 'vitest'
 import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
 import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
 
-function response(documentPath?: string): RpcResponse<{
+function response(hasDocument = false): RpcResponse<{
   writable: boolean
-  documentPath?: string
+  hasDocument: boolean
   namespaces: []
 }> {
   return {
     rpcId: 'settings-document' as never,
     result: {
       ok: true,
-      value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] },
+      value: { writable: true, hasDocument, namespaces: [] },
     },
   }
 }
@@ -32,7 +32,7 @@ function describeFailed(message: string): RpcResponse<never> {
 
 describe('SettingsDocumentStore', () => {
   it('loads provider metadata and asks the settings domain to open its document', async () => {
-    const describe = vi.fn(() => Promise.resolve(response('/home/test/settings.yaml')))
+    const describe = vi.fn(() => Promise.resolve(response(true)))
     const openDocument = vi.fn(() => Promise.resolve(opened()))
     const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
     await controller.load()
@@ -72,7 +72,7 @@ describe('SettingsDocumentStore', () => {
     let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
     const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
     const controller = new SettingsDocumentStore({
-      settings: { describe: () => Promise.resolve(response('/tmp/settings.yaml')), openDocument },
+      settings: { describe: () => Promise.resolve(response(true)), openDocument },
     } as never)
     await controller.load()
     const first = controller.open()
@@ -93,7 +93,7 @@ describe('SettingsDocumentStore', () => {
     const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
     const describe = vi.fn()
       .mockReturnValueOnce(first)
-      .mockResolvedValueOnce(response('/tmp/current.yaml'))
+      .mockResolvedValueOnce(response(true))
     let rejectOpen!: (reason?: unknown) => void
     const controller = new SettingsDocumentStore({
       settings: {
@@ -119,7 +119,7 @@ describe('SettingsDocumentStore', () => {
       settings: {
         describe: vi.fn()
           .mockReturnValueOnce(rejectedFirst)
-          .mockResolvedValueOnce(response('/tmp/current.yaml')),
+          .mockResolvedValueOnce(response(true)),
         openDocument: vi.fn(),
       },
     } as never)

+ 1 - 0
packages/client/ui-settings-general/tests/welcome-notice.spec.tsx

@@ -23,6 +23,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
     settings: {
       describe: () => Promise.resolve(response({
         writable: true,
+        hasDocument: false,
         namespaces: [{
           ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
           schema: {},

+ 9 - 6
packages/client/ui-settings-general/tests/welcome-store.spec.ts

@@ -53,7 +53,7 @@ describe('WelcomeNoticeStore', () => {
     ] as const) {
       const api = {
         settings: {
-          describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
+          describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))),
         },
       }
       const controller = new WelcomeNoticeStore(api as never)
@@ -101,7 +101,7 @@ describe('WelcomeNoticeStore', () => {
         rpcId: 'failed' as never,
         result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
       }),
-      () => Promise.resolve(ok({ writable: true, namespaces: [] })),
+      () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
     ]) {
       const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
       await controller.load()
@@ -112,6 +112,7 @@ describe('WelcomeNoticeStore', () => {
       const controller = new WelcomeNoticeStore({
         settings: { describe: () => Promise.resolve(ok({
           writable: true,
+          hasDocument: false,
           namespaces: [{ ...namespace(), value }],
         })) },
       } as never)
@@ -133,18 +134,20 @@ describe('WelcomeNoticeStore', () => {
     const first = deferred<ReturnType<typeof ok>>()
     const describe = vi.fn()
       .mockImplementationOnce(() => first.promise)
-      .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
+      .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
     const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
     const stale = controller.load()
     await controller.load()
-    first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
+    first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
     await stale
     expect(controller.store.getSnapshot().acknowledged).toBe(false)
 
     const failed = deferred<ReturnType<typeof ok>>()
     describe
       .mockImplementationOnce(() => failed.promise)
-      .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
+      .mockImplementationOnce(() => Promise.resolve(ok({
+        writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
+      })))
     const staleFailure = controller.load()
     await controller.load()
     failed.reject('stale failure')
@@ -154,7 +157,7 @@ describe('WelcomeNoticeStore', () => {
 
   it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
     const write = deferred<ReturnType<typeof ok>>()
-    const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
+    const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
     const controller = new WelcomeNoticeStore({
       settings: { mutate: () => write.promise, describe },
     } as never)

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: f7a3113e25b570d6aa63e0ce4b25d8b143807dfc
-README.zh.md: da1a2a0347150584d0dd040caf92316f5da31837
+README.md: a3c9c56700ddac7c54dab4319124da3e7a64d25a
+README.zh.md: 1b3e4139f94e61788731cc7caa141bc99a3b0e9d

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
packages/host/apiproxy/README.md


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
packages/host/apiproxy/README.zh.md


+ 1 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -2471,7 +2471,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         const exposed = exposedNamespaces()
         return Promise.resolve(ok(request, {
           writable: settings.writable,
-          ...settings.documentPath === undefined ? {} : { documentPath: settings.documentPath },
+          hasDocument: settings.documentPath !== undefined,
           namespaces: settings.describe({ redactSecrets: true })
             .filter(descriptor => exposed.has(String(descriptor.ns)))
             .map(namespaceView),

+ 1 - 1
packages/host/apiproxy/src/api/settings.schema.ts

@@ -32,7 +32,7 @@ export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wi
 /** settings.describe response value. */
 export const settingsDescribeValueSchema = z.object({
   writable: z.boolean(),
-  documentPath: z.string().min(1).optional(),
+  hasDocument: z.boolean(),
   namespaces: z.array(settingsNamespaceViewSchema),
 }) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
 

+ 8 - 9
packages/host/apiproxy/src/api/settings.ts

@@ -53,23 +53,22 @@ export type SettingsPathOpView =
 export interface SettingsApi {
   /**
    * Describe every registered namespace: redacted layered values plus the
-   * serialized schema a client renders its form from. `documentPath` is the
-   * absolute Host path only for a file-backed provider; this method is
-   * loopback-only, and clients use it only to decide whether to offer the
-   * pathless `openDocument` action instead of attempting browser filesystem
-   * access. `writable: false` (read-only provider) tells the client to disable
-   * every write control.
+   * serialized schema a client renders its form from. `hasDocument` reports
+   * whether a file-backed provider owns a local document without exposing its
+   * Host path. This method is loopback-only; `writable: false` (read-only
+   * provider) tells the client to disable every write control.
    */
   describe(request: RpcRequest<{}>): Promise<RpcResponse<{
     writable: boolean
-    documentPath?: string
+    hasDocument: boolean
     namespaces: SettingsNamespaceView[]
   }>>
 
   /**
    * Materialize the configured local document when absent and ask the Host to
-   * open it with the platform default application. The request carries no
-   * path, so the browser cannot choose an arbitrary Host filesystem target.
+   * hand it to the platform text-document opener. macOS forces a text editor;
+   * Linux and Windows use the desktop file association. The request carries
+   * no path, so the browser cannot choose an arbitrary Host filesystem target.
    */
   openDocument(
     request: RpcRequest<{}>, signal: AbortSignal,

+ 31 - 2
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -5,7 +5,7 @@
  * invalidation frames (settings/credentials/models changed).
  */
 
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import z from 'schemastery'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -235,7 +235,7 @@ describe('settings domain', () => {
     const api = createApiProxy(ctx, DEFAULTS)
     const value = expectOk(await api.settings.describe(request({})))
     expect(value.writable).toBe(true)
-    expect(value.documentPath).toBe('/tmp/custom-settings.yaml')
+    expect(value.hasDocument).toBe(true)
     expect(value.namespaces).toHaveLength(1)
     const view = value.namespaces[0]!
     expect(view.ns).toBe('llm-deepseek')
@@ -270,11 +270,40 @@ describe('settings domain', () => {
   it('refuses to open settings when the provider has no local document', async () => {
     const ctx = await harness()
     const api = createApiProxy(ctx, DEFAULTS)
+    expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
     const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
     expect(error.code).toBe('internal')
     expect(error.message).toContain('no local document')
   })
 
+  it('does not prepare or open a settings document after cancellation', async () => {
+    const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
+    const opened: string[] = []
+    const api = createApiProxy(ctx, {
+      ...DEFAULTS,
+      openTextFile: (path) => {
+        opened.push(path)
+        return Promise.resolve()
+      },
+    })
+    const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
+    const cancelled = new AbortController()
+    cancelled.abort()
+    expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
+      .toBe('cancelled')
+    expect(prepare).not.toHaveBeenCalled()
+
+    const pending = Promise.withResolvers<string | undefined>()
+    prepare.mockReturnValueOnce(pending.promise)
+    const duringPrepare = new AbortController()
+    const opening = api.settings.openDocument(request({}), duringPrepare.signal)
+    await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
+    duringPrepare.abort()
+    pending.resolve('/tmp/settings.yaml')
+    expect(expectErr(await opening).code).toBe('cancelled')
+    expect(opened).toEqual([])
+  })
+
   it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
     // The settings seam is general: any plugin may register a namespace for
     // its own configuration. The Web configuration plane remains opt-in, so a

+ 3 - 3
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -96,7 +96,7 @@ function scriptedApi(overrides: {
       ...overrides.goals,
     },
     settings: {
-      describe: r => ok(r, { writable: true, namespaces: [] }),
+      describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
       openDocument: r => ok(r, { opened: true as const }),
       update: err,
       replace: err,
@@ -680,7 +680,7 @@ describe('config unary surface', () => {
     const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
     const api = scriptedApi({
       settings: {
-        describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
+        describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
         openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
         update: record('settings.update', r => ok(r, view)),
         replace: record('settings.replace', r => ok(r, view)),
@@ -699,7 +699,7 @@ describe('config unary surface', () => {
     const c = client(api)
 
     const described = await c.settings.describe({})
-    expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
+    expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
     expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
     const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
     expect(updated.result).toEqual({ ok: true, value: view })

+ 1 - 1
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -220,7 +220,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
     },
     settings: {
       async describe(request) {
-        return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
+        return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
       },
       async openDocument(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }

+ 10 - 0
packages/host/apiproxy/tests/native-path-opener.spec.ts

@@ -48,6 +48,16 @@ describe('native path opener', () => {
     )
   })
 
+  it('uses the Windows desktop association for text documents', async () => {
+    const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
+    await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
+    expect(run).toHaveBeenCalledWith(
+      'powershell.exe',
+      ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
+      expect.any(AbortSignal),
+    )
+  })
+
   it('opens with Linux xdg-open', async () => {
     const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
     await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })

+ 2 - 2
packages/settings/settings-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
-README.md: 16d66e3dd876ff58ba1745655b325212acc04ca1
-README.zh.md: 75c3316bad3005a9e4e895699187a4b16ae8efef
+README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4
+README.zh.md: 3a290c2795e6d1944c5bce6edb99aab53f2728ee

+ 2 - 2
packages/settings/settings-local/README.md

@@ -24,9 +24,9 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
 - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
 - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
 - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
-- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
+- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal.
 - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
-- **Local configuration surfaces receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser never reconstructs `$DSH_HOME` or submits a filesystem target.
+- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.
 
 ## Model Experience
 

+ 2 - 2
packages/settings/settings-local/README.zh.md

@@ -24,9 +24,9 @@
 - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
-- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
+- **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
-- **本地配置界面会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器绝不重建 `$DSH_HOME`,也不提交文件系统目标。
+- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。
 
 ## Model Experience
 

+ 28 - 26
packages/settings/settings-local/src/index.ts

@@ -234,34 +234,36 @@ export class SettingsLocal extends Settings {
     // failure: an existing-but-invalid document must fail loud, never be
     // silently ignored or overwritten.
     yield* super[Service.init]()
-    if (!this.spec.watch) return
-    const watcher = chokidarWatch(this.spec.filename, {
-      ignoreInitial: true,
-      awaitWriteFinish: {
-        stabilityThreshold: this.spec.debounceMs,
-        pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
-      },
-    })
-    watcher.on('all', () => {
-      if (this.closed) return
-      this.queueRefresh()
-    })
-    watcher.on('ready', () => {
-      // The base init's load raced the watcher's own setup: a change written
-      // between that read and the watcher becoming active never fires an
-      // event. One reconcile at ready closes the gap.
-      if (this.closed) return
-      this.queueRefresh()
-    })
-    watcher.on('error', (error) => {
-      this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
-      this.ctx.logger.warn(error)
-    })
+    const watcher = this.spec.watch
+      ? chokidarWatch(this.spec.filename, {
+        ignoreInitial: true,
+        awaitWriteFinish: {
+          stabilityThreshold: this.spec.debounceMs,
+          pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
+        },
+      })
+      : undefined
+    if (watcher !== undefined) {
+      watcher.on('all', () => {
+        if (this.closed) return
+        this.queueRefresh()
+      })
+      watcher.on('ready', () => {
+        // The base init's load raced the watcher's own setup: a change written
+        // between that read and the watcher becoming active never fires an
+        // event. One reconcile at ready closes the gap.
+        if (this.closed) return
+        this.queueRefresh()
+      })
+      watcher.on('error', (error) => {
+        this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
+        this.ctx.logger.warn(error)
+      })
+    }
     yield async () => {
-      // Quiesce: stop accepting events, close the watcher, then wait out any
-      // queued or in-flight operation so nothing publishes after disposal.
+      // Quiesce every operation chain, even when no watcher is configured.
       this.closed = true
-      await watcher.close()
+      await watcher?.close()
       await this.operations
     }
   }

+ 8 - 1
packages/settings/settings-local/tests/lock-race.spec.ts

@@ -72,10 +72,13 @@ describe('writer-lock failure cleanup', () => {
     const dir = await tempDir()
     const path = join(dir, 'settings.yaml')
     const ctx = new Context()
-    const fiber = ctx.plugin(SettingsLocal, { path, watch: true, debounceMs: 0 })
+    const fiber = ctx.plugin(SettingsLocal, { path, watch: false })
     cleanups.push(async () => { await fiber.dispose() })
     await fiber
     const settings = ctx.settings
+    settings.register(settingsNamespace('alpha'), AlphaSchema)
+    const published: number[] = []
+    ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) })
     let markStarted!: () => void
     const started = new Promise<void>((resolve) => { markStarted = resolve })
     let releaseCreate!: () => void
@@ -85,14 +88,18 @@ describe('writer-lock failure cleanup', () => {
 
     const preparing = settings.prepareDocument()
     await started
+    let disposed = false
     const disposing = fiber.dispose()
+    void disposing.then(() => { disposed = true })
     await vi.waitFor(() => {
       expect((settings as unknown as { closed: boolean }).closed).toBe(true)
     })
+    expect(disposed).toBe(false)
     releaseCreate()
     await expect(preparing).resolves.toBe(path)
     await disposing
     expect(await readFile(path, 'utf8')).toBe('')
+    expect(published).toEqual([])
   })
 
   it('surfaces an exclusive document-create failure and releases the lock', async () => {

+ 2 - 2
packages/settings/settings/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/settings/settings/README.md
-README.md: afb4ed5c2b560de1639e773da3447dd79f15c8dc
-README.zh.md: 8a7cc0c555a8d02c36d60f5a0b321cea7b89e382
+README.md: 0841624e553364fa03f7a1f1209aa72ab13d97e4
+README.zh.md: fa84ca198ba7383f8fbd7b29a53d74f2c375bd5e

+ 1 - 1
packages/settings/settings/README.md

@@ -6,7 +6,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
 
 ## Service API
 
-- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Local configuration surfaces use it as availability metadata, never as a browser-selected open target.
+- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target.
 - `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
 - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
 - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.

+ 1 - 1
packages/settings/settings/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 服务 API
 
-- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。本地配置界面仅将其用作可用性元数据,绝不把它当作由浏览器选定的打开目标。
+- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。Host 配置适配器据此派生可用性,而浏览器协议只暴露一个布尔能力,绝不暴露文件系统目标。
 - `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。
 - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
 - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff