feat(web): move the agent plane behind per-session presets

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.
This commit is contained in:
Yichen Jiang
2026-08-03 23:44:09 +08:00
parent 91b55b9245
commit 3d68185480
15 changed files with 564 additions and 11 deletions

View File

@@ -633,6 +633,27 @@ class SubagentSessionOwnership extends Error {
}
/** Requested identity already belongs to a session with another project cwd. */
/**
* The requested preset differs from the one this session already runs.
*
* A session's composition is fixed at creation: its history was produced under
* that preset's tools, so adopting the identity under a different one would
* replay tool calls the rebuilt agent cannot make. Naming a different preset
* is therefore a caller error rather than a switch.
*/
class AgentPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
`session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; `
+ `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`,
)
}
}
class SessionCwdConflict extends Error {
constructor(
readonly sessionId: SessionId,
@@ -745,6 +766,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
targetFor(agent)
}
/**
* Reject an attempt to run an existing session under a different preset.
*
* A caller that names no preset always adopts the session as it is, so the
* common paths — reconnecting, resuming, retrying a create — are unaffected.
* @param sessionId - the identity being adopted.
* @param requested - the preset the request named, if any.
* @param existing - the preset the session was created under, if any.
* @throws when both are present and differ.
*/
function assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new AgentPresetConflict(sessionId, requested, existing)
}
/**
* Resolve the preset an agent will be composed from, and the setup that
* installs it.
@@ -1172,6 +1212,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (inspected.meta.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
assertPresetUnchanged(sessionId, presetId, inspected.meta.agentPreset)
// The stored preset wins over anything the request names: a resumed
// session's history was produced under that composition, and
// rebuilding it differently would replay tool calls the model can no
@@ -1218,6 +1259,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
const agent = await creation
if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
// Beside the cwd check for the same reason, and after the await so it
// covers every path that yields a live agent — freshly created, adopted
// live, resumed from disk, or recovered by the concurrent-creation catch.
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -1649,6 +1694,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
try {
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
} catch (error: unknown) {
if (error instanceof AgentPresetConflict) {
return err(request, {
code: 'agent-preset-conflict',
message: error.message,
details: {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset },
},
})
}
if (error instanceof UnknownPresetError) {
return err(request, {
code: 'agent-preset-not-found',