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
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 summaryblankcolumn, thehost/session-addedframe field, and thehost/commands-changedframe). 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.createresponse / thehost/session-addedframe, 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 plusextend({[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'sAgent.loopCtx— the Session uses it to dispatch its own scoped events. The reverse actx→Session direction is one hop throughsessions.sessionOf(actx)(mirroring host plugins'agent.sessionusage).
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
SessionIdvalue rather than object identity: on the host, agent.id === session id (1:1 on the same axis), agent identity directly reuses theSessionIdbrand, 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 / thehost/session-addedframe); 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 readssummarize()straight from memory; a cold session is alwaysfalse— the lazy-create contract guarantees a never-appended session never enterspersistence.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.blankcolumn, and the requiredblankfield on thehost/session-addedframe (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;onEngagedsynchronously updates the list mirror, converting the currentNew Sessionrow in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown asNew 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.
- The sender's own tab: the successful response to the first
- 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 toNew 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.createprojects 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 ordinarysession.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 SessionupdatedAt, falling back to the WorkspacecreatedAtwhen it has no Sessions, and keeping host order on ties; only with no Workspace at all does itsessions.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, thensessions.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:
rootreceives only the global standard kit, with no session identity or provisioning.session-maybefollows 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 ofuseSession/useInput, andinputActionsmay all be absent. The unkeyed rootSessionMaybeProviderdrives these updates by subscribing to the runtime's atomiccurrentProvideprojection — 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 — whileSessionMaybeProvideInfouses 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'sSessionMaybeEntry.sessionguarantees thatsessionId, every hook source, and every prop exist; each strict entry's error boundary is keyed bysessionId, 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 —useSessionitself 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
blankcolumn and thehost/session-addedframe'sblankfield (see the blank bit above). - The SSE frame
host/commands-changed(a pure invalidation signal); the client routes it into the typed eventscommands/changedandconnection/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 ofcommands/changethroughctx.remote.$on(forwarded Remote events);connection/resetis unchanged, and the invalidation-not-diffing contract this bullet states still holds. command.list/executeandskill.listare uniformly single-addressed bysessionId(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.createrequest shape: workspaceId/cwd as either-or, plus an optional caller-preallocated sessionId (a same-id same-cwd retry is idempotent; a different cwd reportssession-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
selectModeltrio is ready-made, its client consumer not yet built).