Просмотр исходного кода

feat(bash): resolve executor config through the bash settings namespace

The capability's namespace is owned by the seam because it names the
capability, not an implementation: a host composes exactly one provider of
ctx.bash, so both executor families register the same namespace with their
own schema and composition entry without ever colliding, and a settings
document carried between platforms keeps resolving on both.

Both executors read their config through a source thunk, so a stored change
reaches the next command. The constructor checks the schema cannot express
become the section validator, refusing a bad value at the write instead of
at the next command. pwsh re-resolves its executable only when the declared
path changed, so an unrelated settings change never re-probes the filesystem.
Yichen Jiang 1 месяц назад
Родитель
Сommit
ddc1ec2bd2

+ 1 - 1
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 234e07eafdc4a01681ccff4b7ac8290905cd677b
+config-catalog.md: 14610ca9c06d5c4beb6ccb74795e5859952c58a4
 config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287

+ 2 - 2
docs/config-catalog.md

@@ -315,7 +315,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bash/bash-local/src/index.ts:40`](../packages/bash/bash-local/src/index.ts)
+Source: [`packages/bash/bash-local/src/index.ts:41`](../packages/bash/bash-local/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-sandbox`
 
@@ -1237,7 +1237,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts)
+Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts)
 
 ## `@deepseek-ai/dsh-pwsh-sandbox`
 

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: e1c52b700a45e24c78a505bb522f0b3259415187
-module-graph.zh.md: 26306588773d715a88b901edba5391abff19211d
+module-graph.md: 65741283b5a48817bd01c42fe7982fff6c89511a
+module-graph.zh.md: 00ea2925c6636a65e224c899ee264a4e87d4a2cd

+ 6 - 3
docs/module-graph.md

@@ -548,6 +548,7 @@ flowchart TD
   pkg_goal --> pkg_type_meta
   pkg_bash --> pkg_invariants
   pkg_bash --> pkg_sandbox
+  pkg_bash --> pkg_settings
   pkg_bash --> pkg_subprocess
   pkg_fs --> pkg_brand
   pkg_fs --> pkg_invariants
@@ -655,10 +656,12 @@ flowchart TD
   pkg_goal_session --> pkg_session
   pkg_bash_local --> pkg_bash
   pkg_bash_local --> pkg_invariants
+  pkg_bash_local --> pkg_settings
   pkg_bash_local --> pkg_subprocess
   pkg_bash_local --> pkg_timeout
   pkg_pwsh_local --> pkg_bash
   pkg_pwsh_local --> pkg_invariants
+  pkg_pwsh_local --> pkg_settings
   pkg_pwsh_local --> pkg_subprocess
   pkg_pwsh_local --> pkg_timeout
   pkg_fs_local --> pkg_fs
@@ -1325,7 +1328,7 @@ flowchart TD
 | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
 | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
-| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
+| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
@@ -1351,8 +1354,8 @@ flowchart TD
 | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
 | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
 | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
-| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |

+ 6 - 3
docs/module-graph.zh.md

@@ -550,6 +550,7 @@ flowchart TD
   pkg_goal --> pkg_type_meta
   pkg_bash --> pkg_invariants
   pkg_bash --> pkg_sandbox
+  pkg_bash --> pkg_settings
   pkg_bash --> pkg_subprocess
   pkg_fs --> pkg_brand
   pkg_fs --> pkg_invariants
@@ -657,10 +658,12 @@ flowchart TD
   pkg_goal_session --> pkg_session
   pkg_bash_local --> pkg_bash
   pkg_bash_local --> pkg_invariants
+  pkg_bash_local --> pkg_settings
   pkg_bash_local --> pkg_subprocess
   pkg_bash_local --> pkg_timeout
   pkg_pwsh_local --> pkg_bash
   pkg_pwsh_local --> pkg_invariants
+  pkg_pwsh_local --> pkg_settings
   pkg_pwsh_local --> pkg_subprocess
   pkg_pwsh_local --> pkg_timeout
   pkg_fs_local --> pkg_fs
@@ -1327,7 +1330,7 @@ flowchart TD
 | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
 | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
-| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
+| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
@@ -1353,8 +1356,8 @@ flowchart TD
 | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
 | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
 | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
-| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |

+ 2 - 2
docs/subsystems/bash.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 docs/subsystems/bash.md
-bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd
-bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca
+bash.md: 16899d78916c84fbb6f9501b9a278c9749fd144f
+bash.zh.md: 7beae15f9ae46038ce15243ad249533115a938d4

+ 1 - 1
docs/subsystems/bash.md

@@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise<BashRunResult>
 abstract start(spec: BashExecSpec): BashProcess
 ```
 
-Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts)
 
 <a id="ctxbashenv--bashenvregistry"></a>
 

+ 1 - 1
docs/subsystems/bash.zh.md

@@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise<BashRunResult>
 abstract start(spec: BashExecSpec): BashProcess
 ```
 
-Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts)
 
 <a id="ctxbashenv--bashenvregistry"></a>
 

+ 2 - 2
packages/bash/bash-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/bash/bash-local/README.md
-README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
-README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
+README.md: b011db6478db8fa4dbdfef4812355d0ed2c1eba8
+README.zh.md: 1ddaf7c283aad40d14764388b5ec1e8ebbdf6d16

+ 1 - 0
packages/bash/bash-local/README.md

@@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
 ## Behavior
 
 - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
+- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
 - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
 - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
 - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

+ 1 - 0
packages/bash/bash-local/README.zh.md

@@ -23,6 +23,7 @@
 ## 行为
 
 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
+- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
 - **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
 - **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。

+ 2 - 0
packages/bash/bash-local/package.json

@@ -27,6 +27,7 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-bash": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-settings": "^0.0.1",
     "@deepseek-ai/dsh-subprocess": "^0.0.1",
     "@deepseek-ai/dsh-timeout": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
@@ -37,6 +38,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-bash": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-timeout": "workspace:^",

+ 40 - 11
packages/bash/bash-local/src/index.ts

@@ -11,9 +11,10 @@
 
 import { Context } from 'cordis'
 import z from 'schemastery'
-import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
 import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
 import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { installSettingsSection } from '@deepseek-ai/dsh-settings'
 import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
 
 /**
@@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void {
   }
 }
 
+/**
+ * Reject a resolved section this executor could not run with. The schema
+ * expresses neither "positive and finite" nor the timer bound `graceMs` has to
+ * fit, so a stored value is refused where it is written instead of failing at
+ * the next command.
+ * @param config - the resolved section, schema-valid by construction.
+ * @throws Error naming the field that cannot be used.
+ */
+export function assertServiceableBashConfig(config: Config): void {
+  const resolved = config as ResolvedConfig
+  assertPositiveFinite('timeoutMs', resolved.timeoutMs)
+  assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
+  assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
+  assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
+  assertPositiveFinite('graceMs', resolved.graceMs)
+  if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
+    throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
+  }
+}
+
 /**
  * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
  * process-group SIGTERM→SIGKILL escalation are the subprocess service's
@@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor {
     graceMs: z.number().default(DEFAULT_GRACE_MS),
   })
 
+  /** The currently authoritative config: the settings section, or the composition entry. */
+  private source: () => ResolvedConfig
+
   /** Validated config (schemastery applied the defaults before construction). */
-  readonly config: ResolvedConfig
+  get config(): ResolvedConfig {
+    return this.source()
+  }
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
     // Schemastery fills these fields before construction; the type does not encode that step.
-    this.config = config as ResolvedConfig
-    assertPositiveFinite('timeoutMs', this.config.timeoutMs)
-    assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
-    assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
-    assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
-    assertPositiveFinite('graceMs', this.config.graceMs)
-    if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
-      throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
-    }
+    const entry = config as ResolvedConfig
+    assertServiceableBashConfig(entry)
+    this.source = () => entry
+    installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
+      validate: assertServiceableBashConfig,
+      setSource: (current) => {
+        this.source = current as () => ResolvedConfig
+      },
+      // Every field is read through the getter at each command, so nothing
+      // derived from the source needs rebuilding when the document changes.
+      onChange: () => {},
+    })
   }
 
   /**

+ 115 - 0
packages/bash/bash-local/tests/settings.spec.ts

@@ -0,0 +1,115 @@
+/** The `bash` settings section layered over the executor's composition entry. */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import type { Fiber } from 'cordis'
+import { Settings } from '@deepseek-ai/dsh-settings'
+import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
+import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+
+/** The smallest real provider: one in-memory document, always writable. */
+class MemorySettings extends Settings {
+  doc: Record<string, unknown> = {}
+
+  get writable(): boolean {
+    return true
+  }
+
+  protected load(): Promise<Record<string, unknown>> {
+    return Promise.resolve(structuredClone(this.doc))
+  }
+
+  protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+    this.doc = { ...this.doc, [ns]: structuredClone(section) }
+    return Promise.resolve()
+  }
+}
+
+async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
+  ctx: Context
+  settingsFiber: Fiber
+  executorFiber: Fiber
+  bash: LocalBashExecutor
+}> {
+  const ctx = new Context()
+  await ctx.plugin(LocalSubprocessService)
+  const settingsFiber = ctx.plugin(MemorySettings)
+  await settingsFiber.await()
+  const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
+  await executorFiber.await()
+  return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor }
+}
+
+describe('bash settings section', () => {
+  it('resolves the user layer over the composition entry', async () => {
+    const bench = await boot()
+    expect(bench.bash.config.timeoutMs).toBe(60_000)
+
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
+
+    expect(bench.bash.config.timeoutMs).toBe(5_000)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('refuses a stored value the constructor would have rejected', async () => {
+    const bench = await boot()
+
+    await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
+      .rejects.toThrow(/positive finite/)
+
+    expect(bench.bash.config.timeoutMs).toBe(60_000)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('refuses a grace period longer than a timer can carry', async () => {
+    const bench = await boot()
+
+    await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
+      .rejects.toThrow(/graceMs must be no greater than/)
+
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('serves the stored section to every later read', async () => {
+    const bench = await boot()
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
+
+    const spec = bench.bash.resolve({ command: 'true' })
+
+    expect(spec.stdoutMaxBytes).toBe(1_024)
+    expect(spec.workdir).toBe('/tmp')
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('falls back to the composition entry when the settings provider detaches', async () => {
+    const bench = await boot()
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
+    expect(bench.bash.config.timeoutMs).toBe(5_000)
+
+    await bench.settingsFiber.dispose()
+
+    expect(bench.bash.config.timeoutMs).toBe(60_000)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('keeps the composition entry when no settings provider is mounted', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LocalSubprocessService)
+    await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
+
+    expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234)
+    await ctx.fiber.dispose()
+  })
+
+  it('releases the namespace when the executor unloads', async () => {
+    const bench = await boot()
+    expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
+
+    await bench.executorFiber.dispose()
+
+    expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
+    await bench.ctx.fiber.dispose()
+  })
+})

+ 3 - 0
packages/bash/bash-local/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../../subprocess/subprocess"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../../support/invariants"
     }

+ 2 - 2
packages/bash/bash/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/bash/bash/README.md
-README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
-README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
+README.md: a3debe2e511a188f0578a4f9c65f8714ae67a159
+README.zh.md: 0f338d77372b5c62e822a263235e3b6b73f9ceb9

+ 2 - 0
packages/bash/bash/README.md

@@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
 
 Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
 
+`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
+
 ## Vocabulary
 
 `BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.

+ 2 - 0
packages/bash/bash/README.zh.md

@@ -27,6 +27,8 @@
 
 实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
 
+`BASH_SETTINGS_NAMESPACE`(`bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
+
 ## 词汇
 
 `BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。

+ 4 - 2
packages/bash/bash/package.json

@@ -26,14 +26,16 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "@deepseek-ai/dsh-subprocess": "^0.0.1",
     "@deepseek-ai/dsh-sandbox": "^0.0.1",
+    "@deepseek-ai/dsh-settings": "^0.0.1",
+    "@deepseek-ai/dsh-subprocess": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }
 }

+ 12 - 0
packages/bash/bash/src/index.ts

@@ -6,9 +6,21 @@
  */
 
 import { Context, Service } from 'cordis'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
 import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
 
+/**
+ * Settings namespace of this capability, owned here rather than by either
+ * executor family because it names the capability, not an implementation: a
+ * host composes exactly one provider of `ctx.bash` (the win32 layer swaps the
+ * POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
+ * service registration), so the providers share one namespace without ever
+ * registering it twice, and a settings document carried between platforms
+ * keeps resolving on both.
+ */
+export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash')
+
 export { DSH_ENV_PREFIX } from './types.ts'
 export type {
   BashExecRequest,

+ 3 - 0
packages/bash/bash/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../sandbox/sandbox"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../../support/invariants"
     }

+ 2 - 2
packages/bash/pwsh-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/bash/pwsh-local/README.md
-README.md: eb3365b009e3595230e5fb0f616079bd73c55840
-README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
+README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e
+README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22

+ 2 - 1
packages/bash/pwsh-local/README.md

@@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
 The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
 
 - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
+- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
 - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
-- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
+- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
 - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
 - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
 - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.

+ 2 - 1
packages/bash/pwsh-local/README.zh.md

@@ -28,8 +28,9 @@
 作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
 
 - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
+- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
-- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次
+- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统
 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。

+ 2 - 0
packages/bash/pwsh-local/package.json

@@ -27,6 +27,7 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-bash": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-settings": "^0.0.1",
     "@deepseek-ai/dsh-subprocess": "^0.0.1",
     "@deepseek-ai/dsh-timeout": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
@@ -37,6 +38,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-bash": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-timeout": "workspace:^",

+ 57 - 14
packages/bash/pwsh-local/src/index.ts

@@ -15,9 +15,10 @@
 
 import { Context } from 'cordis'
 import z from 'schemastery'
-import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
 import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
 import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { installSettingsSection } from '@deepseek-ai/dsh-settings'
 import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { resolvePwshPath } from './resolve.ts'
 
@@ -96,6 +97,26 @@ function assertPositiveFinite(name: string, value: number): void {
   }
 }
 
+/**
+ * Reject a resolved section this executor could not run with. The schema
+ * expresses neither "positive and finite" nor the timer bound `graceMs` has to
+ * fit, so a stored value is refused where it is written instead of failing at
+ * the next command.
+ * @param config - the resolved section, schema-valid by construction.
+ * @throws Error naming the field that cannot be used.
+ */
+export function assertServiceablePwshConfig(config: Config): void {
+  const resolved = config as ResolvedConfig
+  assertPositiveFinite('timeoutMs', resolved.timeoutMs)
+  assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
+  assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
+  assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
+  assertPositiveFinite('graceMs', resolved.graceMs)
+  if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
+    throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
+  }
+}
+
 /**
  * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
  * files, and process-tree termination are the subprocess service's mechanics;
@@ -114,25 +135,47 @@ export class PwshLocalExecutor extends BashExecutor {
     pwshPath: z.string(),
   })
 
+  /** The currently authoritative config: the settings section, or the composition entry. */
+  private source: () => ResolvedConfig
+
+  /** The declared executable the current {@link pwshPath} was resolved from. */
+  private declaredPwshPath: string | undefined
+
+  /** The pwsh executable resolved from the current config. */
+  private resolvedPwshPath: string
+
   /** Validated config (schemastery applied the defaults before construction). */
-  readonly config: ResolvedConfig
+  get config(): ResolvedConfig {
+    return this.source()
+  }
 
-  /** The pwsh executable resolved once at construction. */
-  readonly pwshPath: string
+  /** The pwsh executable every command runs through. */
+  get pwshPath(): string {
+    return this.resolvedPwshPath
+  }
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
     // Schemastery fills these fields before construction; the type does not encode that step.
-    this.config = config as ResolvedConfig
-    assertPositiveFinite('timeoutMs', this.config.timeoutMs)
-    assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
-    assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
-    assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
-    assertPositiveFinite('graceMs', this.config.graceMs)
-    if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
-      throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
-    }
-    this.pwshPath = resolvePwshPath(this.config.pwshPath)
+    const entry = config as ResolvedConfig
+    assertServiceablePwshConfig(entry)
+    this.source = () => entry
+    this.declaredPwshPath = entry.pwshPath
+    this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
+    installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
+      validate: assertServiceablePwshConfig,
+      setSource: (current) => {
+        this.source = current as () => ResolvedConfig
+      },
+      // Probing the filesystem is the one fact derived from the source: every
+      // other field is read through the getter at each command.
+      onChange: () => {
+        const declared = this.source().pwshPath
+        if (declared === this.declaredPwshPath) return
+        this.declaredPwshPath = declared
+        this.resolvedPwshPath = resolvePwshPath(declared)
+      },
+    })
   }
 
   /**

+ 108 - 0
packages/bash/pwsh-local/tests/settings.spec.ts

@@ -0,0 +1,108 @@
+/** The shared `bash` settings section as the pwsh executor family resolves it. */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import type { Fiber } from 'cordis'
+import { Settings } from '@deepseek-ai/dsh-settings'
+import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
+
+/** The smallest real provider: one in-memory document, always writable. */
+class MemorySettings extends Settings {
+  doc: Record<string, unknown> = {}
+
+  get writable(): boolean {
+    return true
+  }
+
+  protected load(): Promise<Record<string, unknown>> {
+    return Promise.resolve(structuredClone(this.doc))
+  }
+
+  protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
+    this.doc = { ...this.doc, [ns]: structuredClone(section) }
+    return Promise.resolve()
+  }
+}
+
+async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
+  ctx: Context
+  settingsFiber: Fiber
+  executorFiber: Fiber
+  pwsh: PwshLocalExecutor
+}> {
+  const ctx = new Context()
+  await ctx.plugin(LocalSubprocessService)
+  const settingsFiber = ctx.plugin(MemorySettings)
+  await settingsFiber.await()
+  const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
+  await executorFiber.await()
+  return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor }
+}
+
+describe('pwsh executor over the bash settings section', () => {
+  it('resolves the user layer over the composition entry', async () => {
+    const bench = await boot()
+    expect(bench.pwsh.config.timeoutMs).toBe(60_000)
+
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
+
+    expect(bench.pwsh.config.timeoutMs).toBe(5_000)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('refuses a stored value the constructor would have rejected', async () => {
+    const bench = await boot()
+
+    await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
+      .rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
+
+    expect(bench.pwsh.config.timeoutMs).toBe(60_000)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('re-resolves the executable when the stored path changes', async () => {
+    const bench = await boot({ pwshPath: '/opt/first/pwsh' })
+    expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
+
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
+
+    expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('keeps the resolved executable when an unrelated field changes', async () => {
+    const bench = await boot({ pwshPath: '/opt/first/pwsh' })
+    const before = bench.pwsh.pwshPath
+
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
+
+    expect(bench.pwsh.pwshPath).toBe(before)
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('falls back to the composition entry when the settings provider detaches', async () => {
+    const bench = await boot({ pwshPath: '/opt/first/pwsh' })
+    await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
+    expect(bench.pwsh.config.timeoutMs).toBe(5_000)
+    expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
+
+    await bench.settingsFiber.dispose()
+
+    expect(bench.pwsh.config.timeoutMs).toBe(60_000)
+    expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
+    await bench.ctx.fiber.dispose()
+  })
+
+  it('releases the namespace when the executor unloads', async () => {
+    const bench = await boot()
+    expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
+
+    await bench.executorFiber.dispose()
+
+    expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
+    await bench.ctx.fiber.dispose()
+  })
+})

+ 3 - 0
packages/bash/pwsh-local/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../../subprocess/subprocess"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../../support/invariants"
     }

+ 9 - 0
pnpm-lock.yaml

@@ -861,6 +861,9 @@ importers:
       '@deepseek-ai/dsh-sandbox':
         specifier: workspace:^
         version: link:../../sandbox/sandbox
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess
@@ -911,6 +914,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess
@@ -966,6 +972,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess