Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
imccyu 01ecb43ebc docs: state the Host-face rule for the browser e2e and settle the follow-ups
apps/web/tests/README.md records why these e2e type-check in the Host aggregate
and why importing a Client package there pulls its project tree into the Host
build graph, with mirroring as the standing answer. The Agent Note drops the
directory-picker face split (assessed and declined) and the grep-level gate in
favour of that README.

docs: regenerate the catalogs and retarget the moved declarations

The forwarded-event change moved three owner packages' cordis `Events`
declarations and their branded types into client-safe `./types` modules, and
the settings-scope split moves the shell spec into ui-settings-general. Point
the type-equivalence manifest and the affected Agent Note at those homes,
register the new `remote/*` event scope and the `ctx.settingsScope` service in
the catalog partition, and re-run the generators.

`$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note
fences that quote a bare member signature are marked `ignore-check`: they are
declaration fragments, not compilable units.

refactor(client): make ui-settings the settings domain's base layer

The settings-namespace transport lived in client/runtime, where every feature
could value-import it because runtime is a platform module. It belongs to the
settings domain, but moving it into ui-settings as a shared function fails
twice: the client bundle purity gate forbids cross-plugin value imports, and
ui-settings reached ui-sidebar for its shell, so any feature depending on it
closed a cycle through ui-layout and ui-theme.

Both halves move. `ctx.settingsScope` is now a cordis service — the
collaboration shape the purity gate prescribes, and the service proxy binds
`this.ctx` to the caller, so a bound scope's disposer belongs to the calling
fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its
navigation, and the nav-row projection) moves to ui-settings-general, which
already owns the chrome and the General section. What stays in ui-settings is
what carries no `ui-*` dependency: the scope service and the canonical settings
slot types, `settings.general.item` included. That type was parked in the locale
package precisely because the declarer was unreachable without a cycle; every
registrant now depends on this base layer, so it comes home.

The scope CONTRACT stays in client/runtime: a feature service accepts a scope
through its own signature without depending on the surface that binds it.

The forwarded settings invalidation replaces the deleted client-side
`settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches
`$on` through the gateway's Client half plus the allowlist's type-only subpath
rather than api-remotes' Client face: that face imports a Host-tsdown-generated
artifact, and this package is reachable from the Host build graph through its
callers.

refactor(client): reach the settings transport through ctx.settingsScope

Every feature that owns a preference row switches from value-importing a shared
binder to the settings domain's service, and declares the two injections that
binding needs: `settingsScope` for the transport and `remote` for the forwarded
invalidation it subscribes to on the caller's own context.

The rows stay with the features that own the preferences — Language with locale,
Appearance with ui-theme, Composer Enter with ui-conversation. Only their route
to the transport changes, so no settings surface moves and no feature gains a
dependency on the shell.

The `settings.general.item` slot type now arrives from ui-settings, the base
layer every registrant already depends on, which retires the re-export outlet
ui-theme kept and the parked declaration in the locale package.

client/runtime drops its settings-form and schemastery dependencies with the
transport that used them.

test(client): bind the settings transport in the specs that boot a preference row

Every bench that activates a plugin owning a preference row now supplies the two
services that plugin injects: the forwarded-event port and the scope service.
Specs that exercise no settings path get the minimal doubles; the ones that do
drive their refresh chains through `remote/host-event`, the same signal
client/runtime republishes from a forwarded frame, replacing the deleted
client-side `settings/changed` event.

Also fixes a publication defect the built-invariant gate catches once it runs:
api-remotes' invariant companion shared the allowlist module with the package
index, so rolldown hoisted it into a third chunk beside the two bundled entries
— a file the mechanically derived publication list does not carry, leaving an
installed companion unable to import it. The companion now reads the allowlist
through this package's own published `./types` subpath, which the bundle keeps
external, so each entry stays self-contained.

The dynamic-subscription cast in apiproxy is gone: after the vendored cordis
rescope, `on` accepts the rest-parameter handler directly, and the allowlist's
shape assertion still carries the safety argument.

fix(client): carry the settings-scope move across the release manifests

Rebasing onto the publishable release set replaced every manifest's dependency
block, so the packages this change touches restate their additions in the
workspace-protocol form: the base layer's own transport dependencies, and the
`ui-settings` plus `remote` edges each preference-row owner now needs.

ui-settings-general takes clsx with the shell it received, and client/runtime
drops the settings-form and schemastery dependencies that left with the
transport.

fix(api-gateway): give each $on subscription its own registration and containment

Two defects in the forwarded-event subscription table, both raised in review:

A set keyed on listener identity stored one entry when two callers subscribed the
same function object to the same event, so the first frame reached it once instead
of twice and either disposer silenced the surviving registration. Subscriptions are
now records addressed by registration, which is what "the disposer belongs to the
calling fiber" requires.

A listener declared void may still be `async`, and the synchronous `try/catch`
could not see its rejection: the promise was dropped and surfaced as an unhandled
rejection outside the documented containment. Delivery now attaches a rejection
handler when a listener returns a promise, so both failure modes are logged and
isolated alike.

Delivery also iterates a snapshot, so a listener that subscribes or disposes during
a frame no longer changes who receives that frame, and production matches the
TestRemote double instead of relying on live Set iteration order.

Both fixes are pinned by tests that fail against the previous implementation. The
double gains its own spec for the `$mount` refusal and the unsubscribed-name drop —
per-file coverage reaches it — plus a note that it propagates a throwing listener
where production contains one, so no spec mistakes it for the containment guarantee.

Three prose corrections: `assertJsonArgs` states where its throw actually surfaces
(the emitter's listener containment, not load or emit time), the browser e2e README
names every standing Client import rather than claiming one exception, and two
comments and a test title state the forwarded event instead of the deleted
client-side one.

refactor(remote): deliver forwarded frames through ctx.remote.$dispatch

The carrier used to relay each decoded frame over an internal
`remote/host-event` cordis event so the delivery port could stay off the Remote
contract. The relay was the wrong shape twice over: it put a client-face event
into a scan whose subject is the Host vocabulary, forcing a walk exemption for
something that is not a Host event at all, and it made a direct handoff between
two Client plugins look like a broadcast any plugin participates in.

`TypeRTClientRemote` now carries both roles of one surface — consumers subscribe
with `$on`, and whoever owns the Host frame sink hands frames over with
`$dispatch` — so client/runtime calls the Remote service directly and the event
declaration is gone. A cordis service method is the collaboration shape the
client bundle purity gate prescribes, and it needs no relay to satisfy it.

The trade is that the handoff is now developer-visible: any plugin holding
`ctx.remote` can synthesize a forwarded event. That is the exposure the relay
already had — `ctx.emit` was equally reachable — stated in the contract instead
of hidden behind a private subscriber.

runtime reaches `ctx.remote` through the gateway's Client face rather than
api-remotes': that face imports a Host-tsdown-generated artifact, and this
project sits in the Host build graph.

refactor(api-remotes): keep the allowlist value out of types.ts

`src/types.ts` carries only types by package convention, but it held the
forwarded-event array, so the type-only subpath published runtime code. The
array moves to `src/remote-events.ts` and `types.ts` derives its projection from
it; both compiler faces list both files, so the Host forwarding loop and the
consumer key face still read one declaration and the package's exports are
unchanged.

The invariant companion returns to an empty installer. Its dispatch-shape check
was the only reason the companion imported the allowlist, which made the two
bundled entries share a module: rolldown hoisted it into a third chunk that the
mechanically derived publication list does not carry, so an installed companion
could not import it. Dropping the check retires that coupling along with the
subpath-import and bundle-external workarounds it needed, and the shape the
check enforced at runtime is the part the Host face's `TypeRTForwardableEvent`
assertion already refuses at compile time.

test(ui-task): bind the locale plugin's new injections in its bench

The bench boots the real locale plugin, which now injects the settings-scope
service and the forwarded-event port, so it stayed pending and left `ctx.locale`
undefined. Supplies both doubles like the other benches that boot a plugin
owning a preference row.

docs: close the documentation gates for the forwarded-event surface

Regenerates the two graph catalogs and re-records every bilingual pair this
branch edited. Several pairs needed real work beyond the record:

- The generators write only the English side, so the Chinese sides of
  `event-producer-consumer` and `module-graph` had drifted: the former still
  listed the three deleted client-face events and pointed at declaration sites
  this branch moved into `types.ts` modules, and the latter carried a stale
  dependency graph.
- `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides.
- The pairing contract requires both sides to link the same target, so the
  apiproxy README and the design note now link the English note from both
  languages, and the note's code blocks are byte-identical across the pair
  (a translated comment inside a fence counts as divergence).
- `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane
  documents a discipline reviewers apply, so it belongs in the bilingual corpus
  rather than in the pairing exemption list.
- Four fences in the design note are marked `ignore-check`: each quotes a member
  signature, a union arm, or a snippet that names symbols it does not import, so
  none is a compilable unit.

docs(agent-note): transition the forwarded-event note to implemented

The design shipped in this PR, so the pair moves into `implemented/` and takes
that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`,
and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what
pins the behavior) and `## Consequences` (what the shipped shape costs).

Facts that moved after the proposal are corrected rather than preserved: the
allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`,
the delivery port is `$dispatch` rather than an internal cordis event, and the
invariant companion is an explained empty installer. `Verification` states the
two `$on` defects the review found — independent registration identity and
async-rejection containment — since those are now the properties tests pin.

Supersession is partial, so five active notes stay active and gain a
cross-link each: `web-config-plane`, `web-client-session-scope`,
`config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and
`permission-default-for-new-sessions` each described a frame this change
replaced. Only the mechanism sentence is annotated; every conclusion those
notes own is untouched, and `host/models-changed` remains apiproxy's own
derived frame in all of them.

Also pins the disposer's idempotence: calling one `$on` disposer twice must not
splice a surviving twin registration out from under its owner.

fix: docs

fix: test
2026-08-11 19:25:41 +08:00

21 KiB

Agent Note: Web client Agent-scope parity model and the provisioning channel (agents/scope / blank reuse / provide)

Status: implemented

English | 中文

Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (connectWorkspace), the per-session provisioning channel (sessions.provide), and the host wire smalls that carry these capabilities (the summary blank column, the host/session-added frame field, and the host/commands-changed frame). The input state machine and the slash pipeline live in the input machine note; the command business surfaces live in the command surfaces note.

Problem

The web client had a single global session surface: slots all rendered from the root context, so plugins had no notion of "which agent/session is current"; the draft's true copy was buried inside the Session object, leaving any plugin that wanted to participate in input with nowhere to hook in. To support a command/input system, the platform layer first had to answer:

  • Who owns session interaction state (menus, popups, drafts, in-flight requests), and how two sessions are structurally isolated;
  • What a "new session" is before the host entity exists — whether the client must forge an independent life for it;
  • How session-scope components fetch their own session data, instead of props passed down layer by layer;
  • What a user-abandoned new session leaves behind on the host side, and who collects it.

Hard constraints: the host is the single source of truth; every registration goes through a ctx.effect disposer; the scope mechanism matches the host's Agent scope architecture; model-visible ⟺ already in the session log.

Decision

The parity model: client and host share one root state axis

Host-side session.create(workspaceId) produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):

  • Session identity is the host's true form from birth: the sessionId arrives via the session.create response / the host/session-added frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
  • The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls session.create({workspaceId}) on the spot and receives the complete entity.
  • "New Session with no workspace picked" is a pure view state (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
  • A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live.

Agent scope: the actx is the sole session carrier in the client-side cordis world

The runtime's agents/scope.ts matches the host's dsh-scope at the mechanism layer (fiber + tag + filter; no value import: the host package carries the scoped-events Events merge, which would collide with the Context merge inside the client program):

  • createScope(ctx, key): a no-op plugin fiber plus extend({[kScope]: key, [Context.filter]: …}) — the filter lives directly on the actx: untagged listeners receive globally, tagged ones receive only their own scope.
  • Dispatch is the cordis primitives with thisArg = the actx itself: actx.bail(actx, event, req) / actx.emit(actx, event, payload).
  • Session.bindScope(actx): paired exactly once when resolve mints the scope (rebinding throws; dropScope unbinds), mirroring the host's Agent.loopCtx — the Session uses it to dispatch its own scoped events. The reverse actx→Session direction is one hop through sessions.sessionOf(actx) (mirroring host plugins' agent.session usage).

Three deliberate divergences from the host dsh-scope:

  • The filter lives on the actx itself rather than a separate carrier: the host wrapper layer guards the business Agent subject against drifting from the scope key (host events inject the Agent itself as the first argument), while client event payloads carry only an id — there is no subject to protect.
  • Keys compare by branded SessionId value rather than object identity: on the host, agent.id === session id (1:1 on the same axis), agent identity directly reuses the SessionId brand, and a client scope's identity is its wire id.
  • The client scope is an Agent identity scope, not a live-object scope: during a cold session the host Agent object is already disposed while the client actx stays alive (in view) — the identity axis is in strict parity while object hot/cold is deliberately unsynchronized.

id→ctx handoff is allowed in only three kinds of places (business providers never hand off):

  • Slot inject factories: the ctx never enters the render layer; the identity the slot framework hands a component is the sessionId, exchanged back into objects/controllers through service maps.
  • Root coordination services self-addressing: from a projection's sessionId back to the actx via sessions.scope(id).
  • Root untagged listeners: looking up their own store by the payload's sessionId.

Scope lifecycle: anchored to the list mirror — birth is entering view, death is prune

Session instances share the scope's lifecycle; liveness eligibility = host-listed (one criterion, shared by mint and prune):

  • Birth = a session row entering client view (the list baseline pull / the local create() echo / the host/session-added frame); a lazy first resolve mints the scope (resolution is a pure function, render-safe).
  • One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the actx), and the session-keyed slot store. The staged session (= list.current) is the exception: removed while still on stage, it keeps a frozen read-only view, torn down only once the stage moves away.
  • Reopening = lazily rebuilding the instance + open() pulling history (the host session log is the durable truth).
  • Remaining TODO: approval/question frames never enter history and cannot be recovered across a prune (the manager-level pendingBuffers cover only the never-instantiated window).

The blank bit: the empty session's visible projection, conversion, and reuse

A session "materialized but with no first prompt" is governed by the summary-derived bit blank (a derived column, not a header field; SessionHeader stays immutable):

  • The host criterion: session.events.length === 0 (zero log events = no user message yet). A live session reads summarize() straight from memory; a cold session is always false — the lazy-create contract guarantees a never-appended session never enters persistence.list() at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
  • The wire carries it in two places: the required SessionSummary.blank column, and the required blank field on the host/session-added frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
  • The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
    • The sender's own tab: the successful response to the first prompt() flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; onEngaged synchronously updates the list mirror, converting the current New Session row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as New Session, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
    • Other tabs: the host/session-status (running:true) frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
    • Reconnect alignment: session.list's summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
  • List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with session.id === sessions.current, its title forced to New Session. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
  • The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.

connectWorkspace: the sole entry point of New Session

workspaces.connectWorkspace(workspaceId): Promise<SessionId> (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference):

  • The reuse arm: the list mirror is searched for blank && cwd == workspace.path && sessionIds.includes(id) — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the membership reuse fix); a hit returns that id directly, creating nothing.
  • The create arm: on a miss, session.create({workspaceId}) returns the new id.
  • An unknown workspaceId fails loud (never silently creating somewhere else).
  • The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and sessions.binding(id) resolves synchronously — SessionsService.create projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
  • The caller takes the id and does its own sessions.open; sending the first prompt is an ordinary session.prompt — the session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
  • The global New Session button defaults to recentWorkspaceId: first comparing each Workspace's newest Session updatedAt, falling back to the Workspace createdAt when it has no Sessions, and keeping host order on ties; only with no Workspace at all does it sessions.clear() into the no-session view. Create actions inside a Workspace group still hit that Workspace explicitly.
  • At startup the runtime subscribes to the first complete baseline: a successfully restored current session is kept in place; otherwise it automatically calls connectWorkspace(recentWorkspaceId) and opens the returned blank session. The policy settles only once; a later user-initiated clear is never overridden by auto-selection again, and a connect failure waits for the next baseline projection to retry.
  • Re-picking the Workspace in the blank Hero also goes through connectWorkspace; when the target id differs from the current one, the current input machine's non-empty draft moves to the target scope first, then sessions.open(nextId). The old blank entity is not deleted — it merely leaves the list by no longer being current.

Per-session provisioning: the sessions.provide standard-kit channel

The sole provisioning path by which session slot components fetch their own session data. Plugins declare a fixed key map through the static descriptor sessions.provide({hooks, props, resolve}) (a duplicate key throws at registration); resolve(binding) materializes values for a specific session and tears them down with the scope. Web-react's standardKit single loop binds the hooks compartment into use<Name> selector hooks (observableHook→uSES, anti-tearing) and passes the props compartment through as-is.

Slot scope is the closed set root | session-maybe | session:

  • root receives only the global standard kit, with no session identity or provisioning.
  • session-maybe follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, sessionId, the results of useSession/useInput, and inputActions may all be absent. The unkeyed root SessionMaybeProvider drives these updates by subscribing to the runtime's atomic currentProvide projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while SessionMaybeProvideInfo uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's SessionMaybeEntry.
  • session guarantees that sessionId, every hook source, and every prop exist; each strict entry's error boundary is keyed by sessionId, so switching sessions recreates that entry and its session store.

conversation is the resident session-maybe shell: ConversationRoot, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: conversation.session.header carries breadcrumb/tabs/actions above the scrollport, while conversation.session carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (conversation.composer.bar) is itself session-maybe: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict session and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.

  • The runtime's first built-in entry: the 'session' hook — useSession itself rides the same mechanism, no special-casing.
  • Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
  • Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into SessionStandardProps / SessionMaybeStandardProps).

The read-only queue mirror

  • Queue semantics: running does not lock input; ordinary messages queue through session.prompt {mode:'queue'}, and commands never queue.

Host wire smalls

  • The summary blank column and the host/session-added frame's blank field (see the blank bit above).
  • The SSE frame host/commands-changed (a pure invalidation signal); the client routes it into the typed events commands/changed and connection/reset (broadcast after each connection generation is established; wire-derived caches uniformly treat prior state as stale). The commands frame and its typed client event were later replaced by verbatim forwarding of commands/change through ctx.remote.$on (forwarded Remote events); connection/reset is unchanged, and the invalidation-not-diffing contract this bullet states still holds.
  • command.list/execute and skill.list are uniformly single-addressed by sessionId (a session always has an Agent; agentFor's resume semantics come ready-made); the command-surface narrative lives in the command surfaces note.
  • The session.create request shape: workspaceId/cwd as either-or, plus an optional caller-preallocated sessionId (a same-id same-cwd retry is idempotent; a different cwd reports session-conflict).

Alternatives considered

Rejected One-line reason
A client-local Intent + materialize (published CAS / the pendingPrompt attach transaction / the before-create chain) The client is forced to simulate the first half-life the host lacks, breeding a pile of state machinery — published CAS, the attach transaction, partial publication
Host-reserved IDs (a draft Map) The host merely acknowledges a number; the state machine stays on the client untouched
A host draft Session (a Session without an Agent) Every host surface that looks up the Agent must fork for drafts; core would need an attachAgent API plus late-written header cwd
Binding an Agent before cwd (ungrouped) Overturns the readonly header.cwd "created in" invariant, plus the launch-dir side-effect product trap
Passing session context down through React Context Plugins should hold one mental model across host and client; the scope mechanism is isomorphic to the host dsh-scope
A scopeTarget carrier + fused dispatcher (mirroring the host agentEvents) The host wrapper layer guards the business Agent subject against drifting from the scope key; client events have no subject to guard — the filter on the actx plus cordis primitives covers every need
Sessions not holding a ctx (a cordis-free object layer) A red line born only so the filtering unit tests avoid importing cordis, at the cost of two-hop contribute callbacks plus mutable public fields; the host Agent already holds loopCtx
Resident Session instances (resident-instance) The host session log is the durable truth; residency is mere identity convenience, and its misalignment with the scope lifecycle is a source of complexity
Components receiving wiring-callback bundles (two-layer inject→props pass-down) The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props
Swapping the no-session Hero view for the entire session Conversation Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump
Making InputBar itself session-maybe The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary
A dedicated conversion frame session-status(running:true) semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type

Consequences

  • Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
  • The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
  • Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as New Session), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse.
  • The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident card routes activation to the Workspace picker during that interval (decision).
  • Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host selectModel trio is ready-made, its client consumer not yet built).