DSH / Atlas
2026-08-03implementedarchitecture

A session's agent is composed from a preset cordis.yml

会话的 agent 由一份 preset cordis.yml 组装而成

One `dsh` process serves many sessions, but the composition that decides what an agent *is* — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the `cordis.yml` the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (`apps/cli/config/minimal.cordis.yml`, a `--config` overlay that

English

Problem

One dsh process serves many sessions, but the composition that decides what an agent is — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the cordis.yml the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (apps/cli/config/minimal.cordis.yml, a --config overlay that disables tool rows) changes every session at once.

The obvious reading of "let a session pick its composition" is that the loader needs a new tier. It does not. dsh-tools and dsh-system-prompt already file registrations into the calling context's scope layer, and the agent is a registration scope. What was missing is a way to point a whole cordis.yml at one agent's scope.

Decision

A preset is a directory holding one agent.cordis.yml. The agent factory's setup(agentCtx) mounts it as a Cordis include subtree plugged into that agent's scope context. Entry contexts chain to the context a subtree was plugged into, so every registration inside the preset lands in that agent's layer and unwinds with the agent. No registry gains a tier, and no session already running is touched.

Composition splits into two planes, decided by what must be shared rather than by what feels agent-related:

PlaneInstancesContents
HostoneThe registries themselves (tools, systemPrompt, agents, agent-loop, sessions), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host
Agentone per sessionWhat a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy

Model routing stays out of presets. installAgentLlmTarget is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by agent-loop, which lives in the host plane.

The presets the deployment ships are the directories under apps/cli/config/agent-presets/; the roster is that listing, not a list restated here.

Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own isolate vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.

Which preset an unnamed session gets is a user setting (agent-presets.default) layered over the composition's own default, which becomes the base. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a cordis.yml they may not own.

Consequences

The effective default is read per resolution, never snapshotted. A cached value would need a watch subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an agent-preset/selected event records any later blank-session switch, so a reader resolves the pair (resolveSessionPreset) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.

A directly-plugged subtree is invisible to the boot audit. It never links itself to an Entry, so it is absent from ctx.loader.entries() and assertEntriesActivated cannot see it. The mount audits its own rows instead, reading the tree through an Include subclass that publishes it.

A preset can only name a group because the app registers one. Sharing a realm across rows is a cordis:group row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve @cordisjs/plugin-group by name: Node's upward node_modules walk never reaches the harness from there. boot() therefore registers cordis:group beside cordis:include as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the isolate vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.

A preset may not publish into the root service realm. Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that setup never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit.

Failure rolls the agent back. setup runs before publication, so a rejected mount fails ctx.agents.create() and leaves nothing behind. This is why setup is the one supported call site.

A test that the preset file is never rewritten has to be able to fail. The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under fixtures/, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass.

Fiber membership is object identity, not uid. A uid is a per-registry counter, so fibers in two different roots collide on it; comparing by uid made one runtime's subtree answer for a service published in another. ctx.plugin() returns a thenable Object.create(fiber) wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction.

A preset file is an input, never a persistence target. EntryTree.write() persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to [] the first time a session ends. The subtree overrides write() to do nothing.

A plugin that looks itself up in the global registry breaks inside a preset. ctx.tools.register() files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped ctx.tools.get(name) correctly finds nothing. dsh-tool-skill did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name.

An entry-local isolate realm is invisible to the agent's own scope, not only to the host. Only rows inside that group resolve the service. That is what makes a preset's skills registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing.

Switching is allowed only while a 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 agentPreset.select answers agent-preset-locked. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the AgentHandle it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails.

Authoring a preset is an RPC, and a privileged one. A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained read/write/remove beside select. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. list and select deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but session.create already takes an agentPreset, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries bash and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id ([a-z0-9][a-z0-9-]*), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.

A service with a consumer outside the agent plane cannot move into a preset. The aggressive split moved the subagents registry and its spawn/fork backends into the delegation group's entry-local realm, and dsh web then failed to boot: dsh-host-apiproxy is a HOST row that injects subagents to answer the browser's cross-session queries (listChildren, followup), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. workflows stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.

A real-composition test that disables a host row cannot audit that row. The web composition test disabled api-gateway — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off.

A preset's package names must resolve from the harness, not from the preset. EntryTree.import() resolves a row against its own tree's baseUrl, which Include sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward node_modules walk never reaches the installed harness, so every @deepseek-ai/dsh-* row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.

The preset id is model-visible and must be logged. It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside cwd, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.

A durable header field is not durable until every backend writes it. agentPreset landed on SessionHeader with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite sessions row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. summarizeCold had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.

The choice belongs to the screen where it still works. The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is staged: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding sessions.create would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.

A preset multiplies a cost the host was already paying: nothing disposes an agent. Measured against the shipped compositions with --expose-gc, one live agent holds ~0.17 MB on minimal and ~1.31 MB on standard/cordis, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 standard agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. dsh-host-apiproxy discards the AgentHandle it creates, archiveSession only edits the workspace registry, AgentRegistry has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber uid has cleared, and an agent that never dies never clears one.

  • Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam.

Alternatives considered

Add a preset tier to the scoped registries. ScopedLayers.merge() combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes dsh-scope and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns.

Make the agent's scope key the preset. Sessions on one preset would share a layer for free, but per-agent registrations — installAgentLlmTarget, per-agent tool restrictions — would then collide across sessions.

Run each preset as a child process. subagent-dsh-sdk already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one.

Give product subagents global enable settings and a separate settings page. The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools.

Ship one preset for every Codex and Claude Code combination. Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result.

中文

问题

一个 dsh 进程服务多个会话,但决定 agent(智能体)究竟是什么的那套组装——它的工具、人设、提示词段落、委派后端——由启动器所引导的 cordis.yml 一次性固定给整个进程。若某个部署希望一个 benchmark 精简 agent 与一个完整编码 agent 并存,就必须跑两个进程;而现有的变通方案(apps/cli/config/minimal.cordis.yml,一个用来禁用工具行的 --config 覆盖层)会一次性改变所有会话。

对"让会话自选组装"最直觉的理解,是 loader 需要新增一层。其实不需要。dsh-toolsdsh-system-prompt 本就按调用方上下文的 scope 分层归档注册,而且 agent 本身就是一个注册 scope。此前缺的只是一种把整份 cordis.yml 指向某一个 agent scope 的办法。

决策

preset 是一个目录,其中放置一份 agent.cordis.yml。agent 工厂的 setup(agentCtx) 把它作为 Cordis include 子树,挂载到该 agent 的 scope 上下文之下。entry 上下文沿原型链连到子树被挂载时所在的上下文,因此 preset 内部的每一次注册都落进该 agent 的分层,并随 agent 一起卸载。没有任何注册表新增分层,也没有任何已在运行的会话被触及。

组装划分为两个平面,依据是什么必须共享,而不是什么感觉上与 agent 有关:

平面实例数内容
宿主一份注册表本身(toolssystemPromptagentsagent-loopsessions)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主
agent每会话一份单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略

模型路由不进 preset。installAgentLlmTarget 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 agent-loop 解析到,因为后者位于宿主平面。

部署交付哪些 preset,取决于 apps/cli/config/agent-presets/ 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。

挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 isolate 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。

未指名 preset 的会话拿到哪一个,是一项用户设置(agent-presets.default),叠在组装自身的 default 之上——后者成为 base。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 cordis.yml 就能调整的东西。

后果

有效默认值在每次解析时读取,绝不保存快照。 缓存下来就需要一个 watch 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于下一个新建的会话,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话创建时的 id,此后空白期的任何切换由 agent-preset/selected 事件记录,因此读取方解析的是两者之和(resolveSessionPreset)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是当下的默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。

直接挂载的子树对启动审计不可见。 它不会把自己关联到 Entry,因此不在 ctx.loader.entries() 中,assertEntriesActivated 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 Include 子类读取。

preset 能写出 group,是因为 app 注册了它。 跨行共享 realm 就是一个 cordis:group 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 @cordisjs/plugin-group:Node 向上查找 node_modules 的路径从那里永远走不到 harness。因此 boot()cordis:groupcordis:include 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 isolate 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。

preset 不得把服务发布进根 realm。 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 setup 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。

失败会让 agent 回滚。 setup 在发布之前运行,因此挂载被拒绝会让 ctx.agents.create() 失败且不留残留。这正是 setup 是唯一受支持调用点的原因。

「preset 文件从不被回写」这条断言,必须先有失败的可能。 最初那版在一次普通挂载之后断言文件未变,其实什么也抓不到:Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 fixtures/ 下:没有那个覆写,Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被恰恰是证明该缺陷的那次运行改坏,之后每一次运行都拿改坏后的文件作比较从而通过。

fiber 归属判定用对象同一性,而非 uid uid 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 uid 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。ctx.plugin() 返回的是 thenable 的 Object.create(fiber) 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。

preset 文件是输入,绝不是持久化目标。 只要 loader 认为配置变了,EntryTree.write() 就会回写整棵树,而一个插件自我 dispose 就足以触发——销毁 agent 会 dispose 它的整棵子树。若继承该行为,它会重写自己读入的那份组装,实际后果是第一次会话结束时把随附 preset 截断成 []。子树因此把 write() 覆盖为空操作。

按自身名字回查全局注册表的插件,在 preset 里必然失效。 ctx.tools.register() 归档进调用方上下文的 scope,因此挂在 preset 里的插件只为一个 agent 注册,而不带 scope 的 ctx.tools.get(name) 理所当然查不到。dsh-tool-skill 正是这样写的,于是每次 preset 挂载都抛错;现在它与自己注册的那个定义比对。任何希望可被 preset 挂载的插件,都必须持有自己的注册对象,而不是按名字重新读取。

entry 本地 isolate realm 不仅对宿主不可见,对 agent 自身的 scope 同样不可见。 只有该组内部的行能解析到该服务。这正是让 preset 的 skills 注册表归属单个 agent 而非共享的原因——同时也意味着:留在提供方组之外的消费方会悄然解析到宿主注册表,然后什么都不贡献。

只有空白会话才允许切换。 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 agentPreset.select 返回 agent-preset-locked。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 AgentHandle,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。

创作 preset 是一次 RPC,而且是特权 RPC。 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 select 之外新增了 read/write/remove。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。listselect 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 session.create 本就接受 agentPreset,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 bash 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质([a-z0-9][a-z0-9-]*),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。

在 agent 平面之外还有消费方的服务,不能搬进 preset。 激进拆分把 subagents 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 dsh web 直接起不来:dsh-host-apiproxy 是宿主行,它注入 subagents 来回答浏览器的跨会话查询(listChildrenfollowup),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括固定的 Codex 与 Claude Code 产品 provider,都属于宿主平面;preset 只贡献自己的 agent 应看见的委派工具,这些工具解析宿主注册表。workflows 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。

真实组装测试若禁用了某个宿主行,就无法审计该行。 web 组装测试把 api-gateway——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。

preset 的包名必须从 harness 解析,而非从 preset 解析。 EntryTree.import() 按行所属树的 baseUrl 解析,而 Include 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 node_modules 永远够不到已安装的 harness,因此每一个 @deepseek-ai/dsh-* 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。

preset id 对模型可见,必须写入日志。 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 cwd 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。

持久化的头部字段,在每个后端都写入之前都算不上持久。 agentPreset 带着正确的理由落在了 SessionHeader 上,而两个持久化后端都没有携带它:JSONL 头部行、SQLite sessions 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset,所有据以命名它的表层随之失声。summarizeCold 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。

这个选择属于它仍然可用的那个界面。 composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是暂存的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 sessions.create 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。

preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。--expose-gc 对随附组装实测:一个存活的 agent 在 minimal 上约占 0.17 MB、在 standard/cordis 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 standard 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。dsh-host-apiproxy 创建后直接丢弃 AgentHandlearchiveSession 只改工作区注册表,AgentRegistry 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber uid 已清空的记录,而永不死亡的 agent 永远不会清空它。

  • 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。

考虑过的替代方案

在 scope 注册表中新增 preset 分层。 ScopedLayers.merge() 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 dsh-scope 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。

把 agent 的 scope 键设为 preset。 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——installAgentLlmTarget、按 agent 的工具限制——会跨会话相撞。

把每个 preset 作为子进程运行。 subagent-dsh-sdk 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。

给产品 subagent 增加全局启用设置与独立设置页。 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。

为 Codex 与 Claude Code 的每种组合交付一份 preset。 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。