master moved the RPC surface onto the fetch transport, so the two agentPreset
methods arrived there with nothing driving them. Adds the round trip, plus the
two `recompose` edges that had none: an agent that never composed a preset, so
there is nothing to restore, and a restore that fails because the composition
it reaches for is gone — the roster is a live directory.
Marks the deliberate non-Error rejections in the store specs, which is the
branch they exist to cover.
Carries two edits beyond conflict resolution, both forced by what master
brought in:
- `CustomProviderCard`: master added front-end key validation and a
component-level `keyValue` (already trimmed) while still writing
`apiKeyEnv` unconditionally. Kept this branch's blank-key rule and its
committed-profile retry gate, and adopted master's single `keyValue` so
the component has one spelling of the key rather than two.
- `docs/user/guide/providers`: master merged #1810, whose default-model
section still taught overriding the `api-gateway` row in
`$DSH_HOME/config.yaml` — the behavior this branch replaced. Rewritten
for the settings section the picker now writes, plus the review fix from
#1810 replacing the colloquial 挂着 in the opener.
A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.
`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.
The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.
The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
Rewrite the three sibling Agent Note pairs that still described create-by-name
as current (workspace-ui-product-flow, session-list-browsing-and-manual-order,
same-basename-workspace-adoption) and the one-route note's own alternative and
section title; delete scripts/hero-composer-dom-continuity.mjs, which drove the
name dialog removed by the one-route change; mark WorkspaceRegistry.create's
now test-only title parameter with a deletion TODO; pin the retired { name }
spelling as a schema rejection; align the workspace spec on stageDir and the
fixture spec title on path creates.
The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace
Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the
whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's
workspaceRoot config/default and the mkdir branch, the client seam that carried the name
(WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web
--workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as
workspace.rename's duplicate-title error.
The route a new session starts from was frozen into the gateway's
composition entry, so switching models in a conversation reached only that
conversation and every later session went back to the shipped default.
The gateway now owns an `api-gateway` settings section: the entry is the
base layer and the user document layers over it, so `session.selectModel`
records an accepted switch as the default for the next session. The write
is wholesale rather than a merge — switching to a model with no reasoning
effort has to clear a stored one — and a storage failure is reported
without undoing the switch, which already applies to its own session.
`targetFor` now resolves its tiers on every read instead of seeding once:
an explicit selection, else the session's own logged request header, else
the live default. That is what keeps a session that has run a turn deriving
its route from its log forever after, while a session still blank — New
Session reuses one rather than minting another — starts from a default
saved after it was created.
Six review findings on the select surface, all reachable from the wire:
**Resume read the header, not the log.** The switch was recorded as
`agent-preset/selected` and every projection resolved from it, but `agentFor`
still composed from `inspected.meta.agentPreset` — the value written once at
creation. A blank session that switched and then ran turns came back after a
restart under the ORIGINAL preset, restoring that history under the tool set it
was not produced with, which is the mismatch this feature exists to prevent.
`inspected` already carries the events.
**Cold summaries dropped the preset entirely.** `summarizeCold` hand-copied
three header fields and omitted the fourth, so a restored session reported no
preset and the picker showed the deployment default. It now uses the same
projection the attached path does.
**`select` had no gate.** Two concurrent selects both passed the blank check;
the second `unmountPresetFor` then found no record, because the first had
already removed it, and both mounts installed into one agent layer. Selects on
one session now queue, and the blank check is re-read inside the queue. This is
not turn admission — a `session.prompt` racing a switch is the agent loop's to
reserve — but it closes the select-versus-select tear-down.
**A same-id restore was skipped.** The roster is a live directory, so "the same
inputs that worked a moment ago" does not hold: a changed file is exactly how a
same-id reselect fails, and skipping the restore left the agent with no
composition at all.
**`writable` was dead state**, initialized true and never set, so the row could
never disable. It now carries `settings.describe`'s bit — a browser that may
not write settings sees the current default and no control, rather than one
whose write answers `settings-not-exposed`.
**`list` was documented as id-ordered.** It is root-precedence order with each
root's own presets sorted, first root to supply an id winning.
- subagents.history computes its projections best-effort on both arms
(a hostile unit's fold rejection serves the page without the block,
matching the session-list precedent) with dual-arm coverage
- the lifecycle-witness spec probes every field of the seven-key
witness, protecting the key list itself
- list-children's own module docs catch up with the seq-gate contract,
and the design note records the rung-two later-event window as an
accepted, self-healing deviation of the corruption class
`core-web` said neither of the things that matter about it. The `-web`
suffix is a leftover from the whole-process `core-web.cordis.yml` overlay,
and presets are per-session and not web-specific. `core` reads as "the
foundational one" when it is in fact the one with the fewest capabilities.
`minimal` says what it is and orders the shipped set legibly by capability:
minimal, standard, cordis.
Breaking: a session created under `core-web` records that id in its header
and will fail to resolve it on resume. Nothing outside this repository has
shipped, so no migration path is offered.
The identically named `config/core-web.cordis.yml` — the legacy
whole-process overlay behind the web snapshot test — is a different thing
and keeps its name.
Three fixes to the settings surface.
The default never reached settings.yaml. The api-proxy keeps an explicit
allowlist of settings namespaces it exposes to configuration clients, and
`agent-presets` was never added — so both pickers moved and then silently
forgot, which is worse than refusing the control. The host seam was fine all
along; only the wire boundary refused. The regression test fails with the
namespace removed.
The nav row showed the fallback gear and read `Agent preset` in Chinese,
matching neither sibling (通用设置 / 模型). It is now 智能体 with the think
glyph, the only unused icon in the set whose semantics point at the agent
rather than at tuning sliders (Personalization is already the workspace
browser's filter control).
The domain's five registrations were unexercised on both halves of the
carrier, so a method missing from `handler.ts` or `client.ts` would have
surfaced in the browser rather than here. `read`/`write`/`remove` widened
the gap; this closes it for the whole domain.
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.
All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.
Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.
Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.
Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
`agentPreset.select` recomposes one session's agent from a different preset.
It is allowed only while the session is blank — once a turn has run, that
history was produced under the preset's tools and swapping them would strand
logged tool calls, so the attempt answers `agent-preset-locked`.
The agent and the session survive; only the preset subtree is swapped. That
was forced by what the host actually owns: api-proxy discards the `AgentHandle`
it creates, and there is no delete RPC, so neither disposing nor recreating the
session was available. Swapping the subtree is also the better answer — the
session id, its workspace attachment, and its projections all stay put.
`recompose` is unmount-then-mount because two compositions cannot coexist: both
would register the same tool names into one layer. So it resolves the new
preset BEFORE tearing anything down (an unknown id is a no-op) and restores
the previous composition when the new one fails to mount, rather than leaving
the agent with no tools at all. Both paths are pinned by test.
Also restores the English half of the `agentPreset.list` README paragraph,
which was lost before the previous commit — and `verify-translation-pairing
--write` recorded the pair as consistent anyway, because it records whatever
state it finds rather than checking the two sides say the same thing.
`agentPreset.list` gives a browser the deployment's roster so it can offer a
choice when starting a session. Each row carries the id, its `trust`, and
whether it is the current default.
`trust` is on the wire deliberately: a `user` preset is exactly as privileged
as the plugins it names, so a surface that offers one alongside a shipped
preset can say which is which rather than presenting both as vetted.
The domain is read-only. A preset is a composition on disk, so authoring one
is a filesystem act rather than an RPC; and a deployment composing no presets
answers with an empty roster rather than an error, because sharing the host
composition is a valid deployment.
The RPC map made every registration site a type error, so the route, the
response-schema table, the service delegate, and the browser fixture are all
wired rather than only the ones I remembered.
None of these change behavior; each said something that was not true.
`SessionCwdConflict`'s doc block had been left stranded above the
`AgentPresetConflict` inserted under it, so one class carried a comment
about the other and the second carried none.
The roster comment named a `.system` directory that does not exist; the
shipped root is `config/agent-presets/`, and `system` is the trust its
entries carry.
The real-composition test attributed the disabled `api-gateway` row to
"side effects outside this process" alongside the port and the exporter.
It is disabled for a different reason — the api-proxy cannot mount in
this layer at all — and hiding that behind the same phrase would leave a
later layer unable to tell whether the line can come out.
One test claimed to refuse an adoption while asserting only that the
header records the preset; it now says what it checks.
`PERSONA_SECTION`/`PERSONA_ORDER` existed twice, once in the registry
that declares the slot and once restated in the row that replaces it —
a drift that would land a preset's persona beside the deployment's
instead of shadowing it. The registry exports them now.
The preset conflict message read "already runs agent preset undefined"
for a session that records none, which is the shape a deployment with no
roster produces; it names that case instead, with the regression that
reaches it through the gateway.
Finally, `PresetTree.write()` drops the `loader/config-update` the
inherited method emits — recorded on the override, since a future
edit-while-running flow needs its own persistence path.
A preset publishes its services behind `isolate` realms, which is what
makes them per session — and what makes them invisible to every host
context. The api-proxy kept reading the root realm, so requests that are
ABOUT a session but arrive from outside it answered for a singleton that
no longer exists: `goal.pause`/`clear` and `skill.list` returned "this
deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions
whose composition mounts exactly that. Verified against a running host
before and after.
`agentPresets.serviceFor(agent, name)` addresses the instance instead,
reading the same subtree-ownership relation `leakedServices` already
uses, inverted. It is read addressing for a caller holding the agent: a
host row that `inject`s a service cannot use it, because injection
resolves before any session exists — which is why `tools` and
`subagents` stay host-plane and this is not a way around that.
Tool presenters had the same shape and the same cure: `viewFor` looked
definitions up without a scope while the global layer is empty by
design, so every card degraded to the generic renderer. It now takes the
owning agent.
Cold resume through `agentFor()` mounted no preset at all, so every
generic entry point — prompt, models, commands — rebuilt a restarted
session on host tools and the deployment persona. It composes the
recorded preset now, as the other resume path already did.
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.
`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.
A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.
Two bugs the real-composition test caught, both invisible to unit tests:
`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.
`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.
The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.
Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.
The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.
The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
subagents.history drops its session-query read: a live child serves its
in-memory events with the registry watermark snapshot, a cold child goes
through inspectServable with a detached fold, and the retired
SESSION_QUERY_* arms disappear while every wire face stays put. The
design note records the shared read source.
- the subagent projection's empty state becomes a serializable null
sentinel (undefined fields vanish in JSON push frames, leaving stale
identities in clients); consumers treat null and a missing key alike,
and cached sentinels defer to the authoritative refold
- the tool catalog generator mounts the projection registry for
list_agents; the shared unavailable-error copy goes catalog-neutral
- the design note's verification section records the new
subagent-diagnostic snapshot honestly, the 2026-07-22 note's test
inventory is rewritten to the current mechanism, and the session-store
error code is documented
- listChildren contains per-child projection faults on both ladder rungs
(any registered unit's fold/schema rejection maps to that child's corrupt
diagnostic) and pins the whole-enumeration listing-failure rethrow
- the base bundle mounts session-projection (web-app's own insert retired
to avoid the double mount); stale composition comment updated
- the shared projections-unavailable wire face is pinned across
list/history/prompt; retired session-query arms removed from the catalog
paths
- the design note records the unknown-parent semantics shift and the
fold-fault isolation rule
- listChildren reads the session store via strict ctx.get (property proxy
is caller-scoped), orders candidates branchlessly, narrows the cold-read
return type, and pins the cost model and store/registry composition gaps
with tests; per-file coverage restored
- acp-agent and headless-agent compositions mount session-projection; a
keyless snapshot pins the descriptor-less diagnostic row
- api-proxy cold spec pins header-origin ownership and the legacy
descriptor-only opt-out
- design note ships as implemented with its English pairing; companion
notes and core-data-structures pages synced
All agent/* and agent-loop/config-start-failed events take one payload
object carrying the agent subject; waterfall/serial payloads require a
signal and keep next as the final argument. PreStepContext and
RequestFailureContext are unfolded into payloads and retired.
goal/changed follows the same shape so agentEvents keeps its listener
error containment. ReactLoopAgent builds its scope carrier once in the
constructor. Regenerates scope resolvers, tool-cordis api catalog, and
docs catalogs; updates all affected listeners, tests, and the
core-data-structures docs (en + zh).
Move the claimed-message notification loop out of the loop's pre-step
into Inbox.claim(target, turn), so the step-boundary operation publishes
its own claimed notifications like insertions and discards do.
llm.discoverModels was reachable from any declared trusted host. The
method takes a caller-supplied baseURL and makes the host issue a GET to
it, then reports the status or the parsed body — so on a LAN deployment
an anonymous caller had a probe for whatever the host can reach and the
browser cannot, plus a path that carries a draft credential. The
PRIVILEGED_METHODS doc already states the rule this broke: trustedHosts
is a DNS-rebinding fence, not authentication, so the configuration plane
stays loopback-same-origin. It is in that set now, asserted both against
the hand-built fence and over real HTTP beside the catalog reads that
deliberately stay reachable.
supportsDiscovery and listModelDiscoveryNamespaces are gone. The field
was required on the wire and read by nobody: its own contract said a
surface should offer the action "instead of naming an adapter family it
would have to hardcode", while the surface hardcodes llm-pi-ai in two
places and gates the button on whether there is anything to probe. Its
shape did not fit the second caller either — the create card has no row
to read a per-row field from. Keeping a required field alive for a
consumer that may never arrive costs every producer and fixture a value
nobody consults, which is exactly how the fixtures drifted. The registry
that fed it had no other production consumer, so registration and
disposal are now observed through the offer itself.
The Agent Note claimed the key is never logged, which the wire schema
beside it already contradicts, and predated both the provider field and
the catalog-answer path. The two new public types pointed at core.md
without a type-equiv block or manifest entry, so the generated service
catalog named documentation that did not exist.
Clicking "fetch available models" on a built-in provider went to the
network. That is the wrong source: pi-ai's registry is the authoritative
list for its own providers, and it carries the context windows and output
caps a `GET /models` listing does not disclose. Asking api.deepseek.com
what DeepSeek serves is both slower and worse, and against an endpoint
that answers a different shape it failed outright.
Interrogation is still keyed by settings namespace — the provider being
added has no route — but the request may now name the route it is
editing. An adapter that already describes that route answers from what
it knows, needs no endpoint at all, and never touches the network; only a
route the catalog does not describe reaches the wire, and one naming no
endpoint is told to set one or enter its models by hand.
`ConfigurableProviderView` gained `supportsDiscovery` so a surface offers
the action where a namespace can answer instead of hardcoding an adapter
family.
Three narrower corrections ride along. Discovery no longer claims Azure
or Codex: Azure authenticates with an `api-key` header and an
`api-version` query despite its OpenAI lineage, and Codex uses OAuth, so
both reported an authentication failure as a provider with no models.
Cancellation during the body read escaped as the raw abort reason rather
than a coded ABORTED. And the schema comment claiming the probe key is
never logged overstated it: the host neither stores nor returns it, but
it rides the client's outgoing envelope like every other secret-bearing
payload, and redacting that tap is a configuration-plane-wide change.
Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.
Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.
`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.
The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.