feat: slash system / input service / agent scope

This commit is contained in:
imccyu
2026-07-27 03:17:52 +08:00
parent f3a4833dbf
commit a27be43ac1
210 changed files with 15969 additions and 2213 deletions

View File

@@ -0,0 +1,118 @@
# Agent Note: Web client session scope, the provide channel, and the intent data model (runtime scope / provide / before-create)
Status: implemented
English | [中文](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)
> Scope: the client session scope (sctx) and targeted events, session identity and materialize (the published bit), the intent data model (transactional submission), the per-session provide channel (`sessions.provide`), create-time contribution (`client-session/before-create`), the read-only queue mirror (`session/queued`), and the host wire that carries these capabilities (the apiproxy `commands`/`skills` domains, the `host/commands-changed` frame, and the host command registry's `requires` discriminant axis). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md).
## Problem
The web client had a single global session surface: slots all rendered from the root context, so plugins had no notion of "which session is current"; the hero composer was one controlled update chain (`sessions.updateIntent → Session.updatePendingPrompt → notifyNow` same-tick echo) with the draft's true copy 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;
- How a new session keeps the same set of objects from Draft (a local Intent) to materialized (created on the host);
- How session-scope components fetch their own session data, instead of props passed down layer by layer;
- How business parameters at session creation (such as model choice) flow from individual plugins into the create request;
- The wire had nowhere at all to carry a command directory, execution, or the queue.
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
### Session scope: the sctx is the client session's sole carrier in the cordis world
Each client-session logical concept ⟺ exactly one cordis context (the sctx), paired bidirectionally with the business Session. The runtime's `sessions/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, id)`: a no-op plugin fiber plus `extend({[kScope]: id, [Context.filter]: …})` — the filter lives directly on the sctx: untagged listeners receive globally, tagged ones receive only their own scope.
- Dispatch is the cordis primitives with thisArg = the sctx itself: `sctx.bail(sctx, event, req)` / `sctx.emit(sctx, event, payload)` (native emit does not swallow errors; the first synchronous throw propagates to the dispatcher — before-create's abort semantics come straight from this). The host's `scopeTarget` carrier + `agentEvents` wrapper layer above the mechanism is not copied on the client: that layer's job is welding the business Agent subject to the scope key against drift (host events inject the Agent itself as the first argument), while client event payloads carry only an id — there is no subject to protect.
- `Session.bindScope(sctx)`: 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 sctx→Session direction is one hop through `sessions.sessionOf(sctx)`.
- One deliberate divergence from the host: keys compare by branded `SessionId` value rather than object identity (a client session's identity IS its wire id).
Session instances share the scope's lifecycle:
- Liveness eligibility = host-listed the current Intent; mint (lazy first resolve — resolution is a pure function, render-safe) and prune share this single criterion.
- One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the sctx), 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).
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 sctx via `sessions.scope(id)`.
- Root untagged listeners: looking up their own store by the payload's sessionId.
### Session identity and materialize: one published bit
- `Session.published`: a read-only getter, monotonic; `markPublished()` is the single CAS write point where three routes converge — the create response, the `host/session-added` frame, and attach-fail local publication. It does not mean the transport is online (`connection/reset` never lowers it).
- Materialize keeps the same set of instances throughout: the Session, the sctx, and every consumer on it are never replaced.
- Consumers subscribe to the Session snapshot and are driven directly by the published flip; no dedicated event exists.
- The `ClientSessionContext` projection (the runtime pure function `projectSessionContext(snapshot)`): `{sessionId, state:'draft', target:{workspace|workspace-intent}} | {sessionId, state:'materialized'}`; providers receive a fresh projection on every call, never cached.
### The intent data model: the draft steps aside, pendingPrompt demoted to a transaction record
The controlled chain (updateIntent/updatePendingPrompt/sendSession) is deleted with this rework. The draft's single truth moves to the input side (see the input machine note); the Session side keeps only the submit transaction:
- `connect(workspaceId, text)` receives the text snapshotted at the submit instant — `pendingPrompt` is purely the recovery record of this create/send transaction, no longer the draft's owner; failures surface through the snapshot and the input side does its own rollback.
- The workspaces side correspondingly keeps only `materializeIntent()` (Workspace intent → real Workspace); send orchestration moves wholesale up to the input side.
### 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, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
- `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, Workspace picker, the composer stack, and the composer chain retain their React instances across the no-session → blank-session transition; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also remain strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` in the input slot; when a session appears, only that slot is replaced with the strictly bound InputBar. The textarea may be recreated; the Hero and layout skeleton are not.
- 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`).
### Create-time contribution: `client-session/before-create`
- Declared in the runtime (@mode emit); **the Session self-dispatches inside attachPendingPrompt** (`sctx.emit(sctx, …)`, holding its own bound sctx); throw propagation from cordis's native emit IS the abort of this create; with the sctx unbound or already pruned, the contribution is skipped.
- Every create attempt (retries included) gets a fresh write-only typed builder: `SessionCreateOptionMap`'s first cut is `agent/provider` + `agent/model`; writing the same key twice throws; no opaque bag.
- The payload is `{sessionId, target, options}`; sessionId/target are read-only, and listeners write only the keys they own.
- Failure semantics: zero host calls; the draft / plugin stores / Intent are all preserved, the error lands in intent.error, and a retry uses a brand-new builder.
- The finalizer maps the typed keys into `sessions.create`'s `agentOptions` (the host schema is strict and rejects unknown keys; overriding the default provider/model passes through to `ctx.agents.create`).
### The read-only queue mirror
- The new MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match); queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers.
- First-cut queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
### The host wire
- apiproxy adds two domains: `command.list {sessionId?}` and `command.execute {sessionId?, line}` (the signal travels out of band; `matched: false` is a business-level miss, not an error); `skill.list` is dual-addressed `{workspaceId} | {sessionId}` (the host resolves cwd from the workspace registry / the session entity, never through the Agent; querying an unattached session fails loud).
- 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 host `CommandDefinition` is a two-arm union: `requires:'none'` (the handler receives an AgentlessInvocation) | `requires:'agent'` (it receives a CommandInvocation). No default; registering `'none'` at agent scope fails loud at register. `list()` returns only global-layer none; `list(agent)` returns the effective view. /plan, /goal, and all TUI commands are `requires:'agent'`.
- Client payload rules: none never carries a sessionId; agent requires a published session with a stable id — a missing one fails loud, never auto-creates.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| 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 dedicated host-connected event | Consumers are all per-session objects already subscribing to the snapshot; the published flip drives them directly — a one-shot event must not pose as state truth |
| 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 sctx 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 |
| A separate lightweight ClientSession object | published is already the Session's CAS bit; two sources of truth violate single authority |
| 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 surface 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 |
| Create options through an opaque bag | The typed write-once map keeps listener order meaningless and duplicate writes failing loud |
| A requires default, or reserving an 'optional' arm | Pre-release fills it in one pass; the both-states arm has no owner and is not reserved |
| A runtime RPC namespace registration seam | The compile-time-closed method table is the auditable boundary |
## Consequences
- Plugins gain session context isomorphic to the host's: per-session state hangs on the sctx 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.
- With draft ownership moved out, the Session object layer converges to a wire mirror plus the submit transaction, freeing the input system (the next layer) to evolve independently.
- The before-create channel turns "create a session with business parameters" into a single listener registration; the first business consumer is model selection (see the command surfaces note).
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests.
- Known gaps: approval/question recovery across prune (TODO); the unattached skill.list semantics await a ruling.

View File

@@ -0,0 +1,136 @@
# Agent Note: Web client Agent-scope 对等模型与供数通道agents/scope / blank 复用 / provide
Status: implemented
[English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文
> 范围client Agent scopeactx与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`、per-session 供数通道(`sessions.provide`)、队列只读镜像(`session/queued`),以及承载这些能力的 host wire 小件summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.zh.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。
## 问题
web client 只有一张全局会话面slot 全部从根 context 渲染,插件拿不到「当前是哪个 agent/session」的语境draft 真身埋在 Session 对象里,任何要参与输入的插件都无处下手。要支撑命令/输入体系,平台层必须先回答:
- 会话交互态菜单、popup、草稿、在途请求归谁持有双会话如何结构性隔离
- 「新会话」在 host 实体存在之前是什么——client 要不要为它造一段独立生命;
- session-scope 组件如何「自己拿会话数据」,而不是层层下传 props
- 用户放弃的新会话在 host 侧留下什么,谁来收。
硬约束host 是唯一真源;一切注册走 `ctx.effect` disposerscope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入 session log。
## 决策
### 对等模型client 与 host 同一根状态轴
host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd原子大礼包不拆client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间client 为它铸 Agent scopeactx + provide + 输入面全套挂上):
- 会话身份自出生即为 host 真身sessionId 由 `session.create` 响应 / `host/session-added` 帧带来client 侧一切寻址scope tag、slot store 键、RPC 地址)用的都是同一个 id。
- 实体化时点 = 用户选定 Workspacecwd 确定的瞬间client 当场调 `session.create({workspaceId})`,拿到完整实体。
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何 session/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本
- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件goal/plan/skill/…它与任何会话无异slash/plan 天然全活。
### Agent scopeactx 是 client 侧 cordis 世界的唯一会话载体
runtime `agents/scope.ts` 与 host `dsh-scope` 机制层一致fiber + tag + filter 过滤;不 value-importhost 包携带 scoped-events 的 `Events` merge进 client program 撞 Context merge
- `createScope(ctx, key)`no-op plugin fiber + `extend({[kScope]: key, [Context.filter]: …})`——filter 直接住 actxuntagged listener 全局可收tagged 只收本 scope。
- 派发就是 cordis 原语thisArg = actx 本身:`actx.bail(actx, event, req)` / `actx.emit(actx, event, payload)`
- `Session.bindScope(actx)`resolve 铸 scope 时单次配对(重复绑 throwdropScope unbind镜像 host `Agent.loopCtx`——Session 用它自行派发 scoped 事件。actx→Session 反向走 `sessions.sessionOf(actx)` 一跳(镜像 host 插件 `agent.session` 用法)。
与 host dsh-scope 的有意分歧三条:
- filter 住 actx 自身而非独立 carrierhost 包装层护的是「业务 Agent subject 与 scope key 不漂移」host 事件首参注入 Agent 本体client 事件 payload 只带 id、无 subject 可护。
- key 用品牌 `SessionId` 值比较而非对象身份host 里 agent.id === session id1:1 同轴agent 身份直接复用 `SessionId` 品牌client scope 的身份即 wire id。
- client 是 **Agent 身份** scope 而非活对象 scopecold 会话期 host Agent 对象已 dispose 而 client actx 存活(视野内)——身份轴严格对等、对象冷热有意不同步。
id→ctx 换乘只许三类位置(业务 provider 永不换乘):
- slot inject 工厂ctx 不进渲染层slot 框架交给组件的身份就是 sessionId经服务 map 换回对象/controller。
- root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。
- root untagged listener按 payload 的 sessionId 查自有 store。
### scope 生命周期:挂靠 list mirror出生即视野、死亡即 prune
Session 实例与 scope 同生命周期,存活资格 = host listed一个判据mint 与 prune 共用):
- 出生 = 会话行进入 client 视野list 基线拉取 / `create()` 本地回声 / `host/session-added`lazy 首次 resolve 铸 scoperesolution 纯函数、渲染安全)。
- prune 一次同拆三样Session 实例、scope fiber级联挂在 actx 上的一切消费者、session-keyed slot store。staged session= `list.current`例外被移除仍在台上时保留冻结只读视图stage 移走才拆。
- 重开 = lazy 重建实例 + `open()` 拉 historyhost session log 是持久真相)。
- 遗留 TODOapproval/question 帧不进 history跨 prune 不可恢复manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。
### blank 位:空会话的可见投影、转正与复用
「实体化但无首讯」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段SessionHeader 保持不可变):
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息。live 会话 `summarize()` 内存直读cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`JSONL/SQLite 两后端均已实证真 lazyblank 从不落盘。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running首次 running 必然已非 blank
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律store 保留全部行Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
- 残留账零 GC刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
### connectWorkspaceNew Session 的唯一入口
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):
- 复用臂list mirror 中找 `blank && cwd == workspace.path`host realpath 规范 canon 直等比较),命中直接返回该 id不新建。
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud不静默创建到别处
- 解析保证两臂同契约promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
- 调用方拿 id 自行 `sessions.open`;首讯发送就是普通 `session.prompt`——会话本来就在,失败即普通 prompt 失败draft 文本还在 machine 里,重试即再次发送。
- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无 session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope`sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。
### per-session 供数:`sessions.provide` 标准件通道
session slot 组件「自己拿 session 数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw`resolve(binding)` 在确定 session 下物化值并随 scope 拆web-react `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器 hook`observableHook`→uSES防 tearing、props 格原样透传。
slot scope 是闭集 `root | session-maybe | session`
- `root` 只拿全局标准件,不接收 session 身份或供数。
- `session-maybe` 跟随 current session但组件实例不因 id 有无或切换而换 key无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key切换 session 会重建该 entry 及其 session store。
`conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/viewcomposer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`session 出现后把输入体换成严格绑定的 InputBartextarea 允许重建Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内InputBar 不因 phase 翻转而重建。
- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。
- Concurrent 纪律:渲染平面只从 hooks 格读uSES 一致性保证props 格回调只在事件 handler 空间用;描述符解析 render-safe幂等缓存、废弃渲染残留由 prune 收尸)。
- 第三方组件值零依赖,类型一行 type-only importdeclaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
### 队列只读镜像
- MuxFrame `session/queued`Session 持只读 inbox 镜像预览截断、steering 按 source 匹配退休queue 帧不进 history纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。
- 队列语义running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
### host wire 小件
- summary `blank` 列与 `host/session-added``blank` 字段(见上文 blank 位)。
- SSE 帧 `host/commands-changed`纯失效信号client 路由为类型事件 `commands/changed``connection/reset`连接代建立后广播wire 派生缓存一律视旧态为 stale
- `command.list/execute``skill.list` 一律 `sessionId` 单址(会话恒有 Agent`agentFor` 的 resume 语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。
- `session.create` 请求形状workspaceId/cwd 二选一 + 可选调用方预分配 sessionId同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。
## Alternatives considered
| 弃案 | 一行理由 |
|---|---|
| client-local Intent + materializepublished CAS / pendingPrompt attach 事务 / before-create 链) | client 被迫模拟 host 缺失的前半段生命,养出 published CAS、attach 事务、部分发布一坨状态机 |
| host 预留 IDdraft Map | host 只认了个号,状态机原封留在 client |
| host draft Session有 Session 无 Agent | 每个查 Agent 的 host 面都要为 draft 分叉core 要开 attachAgent 缝 + header cwd 后写 |
| 无 cwd 先绑 Agentungrouped | header.cwd readonly "created in" 不变性被推翻 + launch-dir 副作用产品坑 |
| React Context 层层传会话语境 | 插件在 host/client 两侧应是一个心智模型scope 机制与 host dsh-scope 同构 |
| `scopeTarget` carrier + 融合派发器(镜像 host `agentEvents` | host 包装层护的是「业务 Agent subject 与 scope key 不漂移」client 事件无 subject 可护filter 住 actx + cordis 原语覆盖全部需求 |
| Session 不持 ctx对象层 cordis-free | 只为筛选单测不引 cordis 而生的红线,代价是 contribute 两跳回调 + 可变公有字段host Agent 本就持 loopCtx |
| Session 实例常驻resident-instance | host session log 即持久真相;常驻仅为身份便利,与 scope 生命周期错位是复杂度之源 |
| 组件收 wiring 回调包inject→props 两层下传) | 标准件通道让组件自取;公共面收敛为 hooks + 稳定 props |
| Hero 无 session 视图与 session Conversation 整支互换 | 即使外层 layout 不变Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 |
| 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 |
| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正blank 会话从不 running加帧是 wire 多一型换零信息 |
## 后果
- 插件获得与 host 同构的会话语境per-session 状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」slash/skill 等 provider 一律以 sessionId 直接寻址。
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有契约(重启蒸发),常规上限靠同 Workspace 复用。
- 代价id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价单一状态轴换来的
- 已知欠账approval/question 跨 prune 恢复TODO模型选择以 live-mutation 形状回归host `selectModel` 三件套现成,等独立分支)。

View File

@@ -0,0 +1,63 @@
# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-subagent / ui-models)
Status: implemented
English | [中文](2026-07-25-web-command-surfaces-and-assembly.zh.md)
> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the skill / subagent reference sources, the /model command surface and its create-time contribution (ui-models), and fixture command routing plus assembly acceptance (the slash-flow snapshot). The carrying wire and the `requires` discriminant axis live in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md).
## Problem
The pipeline was ready but command knowledge had no landing spot: host-side `ctx.commands` and `ctx.skills` were complete while the web channel had no command capability. The business layer had to answer:
- Command UI takes more than one shape (execute on the spot, pop a select box, backfill and keep typing arguments) — how do business packages ship with zero skeleton changes;
- When is the directory fetched: pulling on every menu open is too slow, while a resident cache needs invalidation and reconnect stories; what directory does each of the two states — Draft (agentless) and materialized — see;
- How a host command's Agent dependency is honored on the client side (no sessionId allowed before published);
- How business parameters at session creation (model selection) ride the before-create channel as a replicable onboarding pattern;
- Assembly-level acceptance: with the layers split apart, how the user-visible main chain is pinned once they come together.
## Decision
### ui-command: a `CommandService` + a per-key `CommandDirectory` + a per-session `PopupSelectController`
- The directory is compartmented by capability key — `agentless` (shared by all Drafts, `command.list({})`) / `agent:<id>` (one compartment per materialized session, `command.list({sessionId})`), with per-key single-flight + an epoch guard (an old pull never overwrites newer state); `commands/changed` soft-invalidates every key (the old snapshot keeps serving while the repull runs in the background), `connection/reset` hard-invalidates agent:* and rewarms; Enter strong-waits on the current key, and a failure keeps the draft with no downgrade.
- `register(contribution)` registers client commands (a descriptor + `available(projection)` + a popupSelect spec); candidate synthesis puts capability before query, and a host/contribution name clash fails loud.
- The three command kinds derive from the registration surfaces; developers never declare positions: a host descriptor with `input` = **leadingInput** (backfill `/name ␣` + claim, keep typing arguments, leading position only); a client-registered popupSelect spec = **popupSelect** (the official select-box shell, business ships zero components); neither = **execute** (run on selection, zero UI).
- The dispatch decision table: the menu can trigger all three kinds; Space recognizes only leadingInput (the misfire defense: irreversible side effects keep explicit entry points only); Enter runs execute / opens the shell only on a bare token, while leadingInput tolerates trailing arguments.
- The popup from `popupFor(sctx)`: search filters locally, select is single-flight, the projection is captured at open, onSelect consumes the token through the consume-token event only on success, a failure is retained for retry, and a session switch merely hides it. The popup shell is a transient layer (never in the state machine): the box holds focus, Enter/↑↓/Escape belong to it, and clicking outside the box dismisses (clicking the textarea also returns focus).
### Reference sources and business packages (seeing only projections plus their own apply closures, on the root ctx)
- **ui-skill**: `state:'draft' + workspace``skill.list({workspaceId})`; `materialized``skill.list({sessionId})`; `workspace-intent` → empty candidates, zero RPC. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
- **ui-models**: `command.register({name:'model', available: () => true, ui: popupSelect})`; options are two static entries; a Draft onSelect writes its own per-session store (`Map<SessionId, SnapshotStore>` + a scope disposer); a materialized onSelect fails loud because the host has no model-update capability; the root registers a before-create listener that reads the store by payload id and writes `agent/model`**the reference implementation for a business command party onboarding the before-create channel** (goal and successors follow it).
### Fixture command routing and assembly
- The connection fixture adds command routing (fixture + fake-api): the keyless rig can run the complete command flow (directory, execution, popup selection).
- The apps/cli assembly mounts all the new packages; the tsconfig path map / reference sets are filled in; catalogs/docs are regenerated with the wire and events.
### Assembly-level acceptance: the slash-flow snapshot
`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the Draft `/` menu contains /model → popup selection → consume token → send materializes (the first create carries `agentOptions.agent/model` on the wire) → textarea DOM identity unchanged. Two workspace-flow assertions pin the push channel behind failure backfill.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Inline prompt dispatch (command text riding the message into the host for parsing) | Conflates the command and message planes; command execution being independent of the message queue is existing host semantics |
| A bridge materializing skills as commands | Skills have their own directory; N registrations would be a detour; the tag form naturally avoids the command plane |
| A `skill.invoke` RPC | The host has no such operation; skill references are plain text riding prompts |
| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); text-as-truth plus structured occurrence records suffices |
| Client packages self-reporting command directories | The host is the single source of truth; the client only reads descriptors, with `commands-changed` pushing invalidation |
| Stuffing /model into ui-command | Business command parties need a standalone package shape as the onboarding template; ui-command holds only the three-kind semantics and the popup shell |
| Dedicated commandresult / commandpanel slots | Results go through notices; the popup shell is a skeleton-internal overlay; rich result cards sit in the ledger |
| An agent-type directory as the `@` source | No type registry exists; the live-session snapshot already covers it |
| A PickAction/EnterCommand class family (class-inheritance pick products) | Cross-package runtime values break client bundle purity; pure data interfaces plus closure methods are equivalent |
## Consequences
- Shipping a business command = a host registration (with requires) plus one client `command.register` (popupSelect) or zero registration (execute/leadingInput derive automatically), with zero skeleton changes; the cost is that the three-kind semantics concentrate in ui-command, and a hypothetical fourth kind means changing it.
- The resident directory cache plus push invalidation buys zero-latency menus and reliable enter adjudication; the cost is three invalidation paths (the change frame, reconnect, the epoch guard) that all need tests pinning them.
- ui-models closes the first business loop through before-create, giving later business parties (goal, model extensions) a pattern to copy verbatim.
- Known gaps: the host model-update capability has no workstream (materialized model selection fails loud); per-agent command shadowing is not on the wire; the queue's second cut (per-item Inbox operations), rich result cards, and roster configurability sit in the ledger awaiting their triggers.

View File

@@ -0,0 +1,62 @@
# Agent Note: Web 命令业务面与装配ui-command / ui-skill / ui-subagent
Status: implemented
[English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文
> 范围命令目录缓存与三型判定ui-command、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.zh.md)。
## 问题
管线就绪但没有命令知识的落点host 侧 `ctx.commands``ctx.skills` 完整而 web 通道无命令能力。业务层要回答:
- 命令 UI 不止一种形态(当场执行、弹选择框、回填后继续打参数)——业务包如何零骨架改动上架;
- 目录何时拉取:每次开菜单现拉太慢,常驻缓存就要有失效与重连故事;
- 会话恒 agent-backedSession+Agent 同瞬出生client 命令面以什么地址兑现 host 的 per-agent 有效目录;
- 装配级验收:拆开的各层合起来,用户可见主链如何钉住。
## 决策
### ui-command`CommandService` + session 键控 `CommandDirectory` + per-session `PopupSelectController`
- 投影 `ClientSessionContext { sessionId }` 自持于 ui-slash 契约types.ts会话恒 agent-backed会话身份即命令能力的全部投影wire 以 `{sessionId}` 寻址(`command.list` / `command.execute` 均是host 从会话 header 解析 Agent
- 目录按 `SessionId` 分格per-key single-flight + epoch guard旧拉取永不覆盖新态`commands/changed` 全 key 软失效(旧快照继续服务、后台重拉)、`connection/reset` 全 key 硬失效并预热Enter 强等当前 key、失败留草稿不降级。预热挂 source 的 `warm` 钩子——scope 出生时对全 roster 一次,即覆盖整个会话生命周期(会话能力自出生恒定)。
- `register(contribution)` 注册 client 命令descriptor + `available(projection)` + popupSelect spec候选合成 = host 目录 + contribution 可用性过滤,再过 query/positionhost/contribution 重名 fail loud。
- 命令三型按注册面派生开发者不声明位置host descriptor 带 `input` = **leadingInput**(回填 `/name ␣` + claim继续打参数仅限行首client 注册 popupSelect spec = **popupSelect**(官方选择框壳,业务零组件);两者皆无 = **execute**(选中即执行,零 UI
- 判定决策表菜单可触发三型Space 只认 leadingInput误触发防线不可逆副作用只留显式入口Enter 裸 token 才 execute/开壳、leadingInput 容忍尾随参数。
- `popupFor(actx)` 的 popupsearch 本地过滤、select single-flight、open 时捕获投影、onSelect 成功才经 consume-token 事件消 token、失败保留可重试、session 切换只隐藏。popup 壳是瞬态层不进状态机框持焦点、Enter/↑↓/Escape 归它、点框外即 dismiss点 textarea 同时归还焦点)。
### 引用源(只见投影 + 自家 apply 闭包的 root ctx
- **ui-skill**`skill.list({sessionId})` 按会话寻址host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome`/name ` 原文,决策 21`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子引用不进命令裁决。skill 引用以原文随普通 prompt 走命令平面之外tool-skill 不变session-prefix 目录提供协作关联)。
- **ui-subagent**:候选零 RPCsessions.list 快照按 parentId/running 过滤pick 产出 text outcome`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
### fixture 命令路由与装配
- connection fixture 补命令路由fixture + fake-apikeyless 台架可跑完整命令流目录、执行、popup 选择)。
- apps/cli 装配挂全部新包tsconfig path map / reference 集补齐catalog/docs 随 wire 与事件再生成。
### 装配级验收slash-flow 快照
`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链assembled keyless包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。
## Alternatives considered
| 弃案 | 一行理由 |
|---|---|
| prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 |
| skill 物化为 command 的桥 | skill 自有目录N 笔注册是绕路;标签形式天然避开命令平面 |
| `skill.invoke` RPC | host 无此操作skill 引用是随 prompt 的普通文本 |
| 新 ContentBlock 引用类型 | 全链路成本adapter/UI/compaction文本即真身 + 结构化 occurrence 记录已足够 |
| client 各包自报命令目录 | host 是唯一真源client 只读 descriptor`commands-changed` 推失效 |
| `requires: 'none' \| 'agent'` 判别轴agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner整轴回退 master 形状,待真需求重开 |
| 专用 commandresult / commandpanel 坑位 | 结果走 noticepopup 壳是骨架内浮层;富结果卡入台账 |
| agent-type 目录做 `@` 源 | 无类型注册表live-session 快照已覆盖 |
| PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 |
## 后果
- 业务命令上架 = host 注册 + client 一笔 `command.register`popupSelect或零注册execute/leadingInput 自动派生),零骨架改动;代价是三型语义集中在 ui-command假想的第四型意味着改它。
- 常驻目录缓存 + 推失效换来菜单零延迟与回车裁决可靠代价是三条失效路径change 帧、重连、epoch guard都需测试钉住。
- sessionId 寻址让 host 的 per-agent 有效目录(全局 + scoped shadows直接上 wireclient 原样呈现。
- 已知欠账popupSelect 壳暂无已上架业务消费者(模型选择等 #600 的 host `selectModel` 以 live-mutation 形态回归,届时作接入样板);队列第二刀(逐项 Inbox 操作、富结果卡、roster 可配置性入台账待触发。

View File

@@ -0,0 +1,129 @@
# Agent Note: Web input state machine, composer slots, and the slash pipeline (ui-conversation input / ui-slash)
Status: implemented
English | [中文](2026-07-25-web-input-machine-and-slash-pipeline.zh.md)
> Scope: the input state machine (the occurrence table + claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-slash), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / intent transaction model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory.
## Problem
Two composers, each a law unto itself: hero (EmptyState, the controlled chain writing straight into the Session) and the in-conversation InputBar (a plain controlled textarea) — behavior, draft ownership, and send path all inconsistent. To bring the three trigger families — `/` commands, skill references, `@` references — onto the input surface, these had to be answered:
- How the three trigger families layer, and who holds knowledge of "commands" versus who stays zero-knowledge;
- How the input box expresses "command mode" — derived from the draft text or explicit state? What do backspace, enter, space, and pasting a whole line each mean;
- Submission is an asynchronous transaction (an RPC round trip) — how are stale-result backwash, session switching, and React concurrent replay defended;
- How reference chips are represented on a plain textarea, and who owns undo / clipboard / paste matching / model serialization;
- How cross-plugin input rewrites (menu backfill, reference insertion, token consumption) achieve dependency inversion;
- How a new session keeps the same textarea from Draft → materialized.
Hard constraints: components mount through slots only; presentation artifacts never enter the session log; the keyboard path is IME-safe throughout.
## Decision
### The input state machine (`InputMachine`)
A pure state machine, events in / effects out, clock injected. Four phases (plain / adjudicating / claimed / submitting). Command mode is **never derived from the draft**; the pick paths establish it explicitly at discrete moments; the claim is watched by `draft.startsWith(token)`, with a backspace break releasing automatically; the claim shape is `{token, hint?}` (hint feeds ghost text).
The event surface (`dispatch(ev)` is the single write entry; one transaction per event):
- `draft-changed {draft, editRange?}` — the textarea's full draft; editRange narrows the occurrence-shift computation, defaulting to a shared prefix/suffix scan.
- `newline {selection}` — the Ctrl+Enter line break (not via the browser's execCommand: under self-managed undo a browser write forks two histories).
- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}` — the machine side of the three bail events; span CAS = draftRev equality.
- `set-invalid {invalidIds}` — the style bit for owner-resolution results (not a transaction).
- `undo` / `redo` — the self-managed transaction log (a ring of 100; single-character typing merges within injected-clock windows; a successful submit clears the log).
- `paste-begin {text, selection, components?, generation?}` — the paste plus hot-snapshot synchronously matched components in one transaction (one Undo returns to before the paste); opens a PasteMatchAttempt.
- `paste-upgrade {attemptId, span, reference}` — an asynchronous match upgrade as its own transaction (Undo in two steps); the attempt stays current, and insertedRange shrinks with each upgrade.
- `invalidate-paste` — attempt-ending gestures observed at the DOM layer (caret/selection operations and the like).
- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires).
The effect surface (executed by the shell): `adjudicate` (calls SlashController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `notice`.
The occurrence table and the chip's three projections:
- Each reference occupies one `U+FFFC` in the draft; a table entry is `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`; same-named chips stay independent through occurrenceId.
- Every edit updates the draft and the table in one transaction: ranges shift; a deletion/replacement intersecting a placeholder acts on the whole chip.
- The single-character placeholder makes keyboard atomicity mostly hold natively (the caret has no interior position; Backspace / arrow keys / Shift extension natively take the whole chip); a mouse click on a chip goes backdrop hit → whole-chip setSelectionRange.
- The visual projection = label: the backdrop renders the chip at the placeholder offset (the textarea glyph is invisible), with invalid taking the invalid style.
- The clipboard/persistence projection = clipboardText: copy/cut expands placeholders inside the selection; the draft-persistence mirror writes the same projection (the chat store always holds plain text; the refresh seed semantics = select-all copy → reopen → paste, with chips degrading to text across a refresh).
- The model projection = generated per chip at submit through the source's `codec.serialize` (owned by the submit attempt's signal and stale guard; a missing owner / failure / cancel means no send, never a downgrade to `/name`).
### Cross-plugin input rewrites: three scoped bail events
The contract is declared in ui-slash (the bottom of the dependency chain); producers dispatch via `sctx.bail(sctx, ...)`, and the only consuming side is the three listeners the hub hangs on the sctx when building the shell; returning `true` ⟺ the machine passed the phase and CAS guards and actually rewrote (emitting the event ≠ a successful modification; whether Space gets `preventDefault` follows the return value):
- `slash/input-begin-command` `{claim, span}` — backfill of the command claim adjudicated from a menu pick / Space (dispatched by the SlashController).
- `slash/input-insert-reference` `{reference, span}` — reference chip insertion (dispatched by the SlashController).
- `slash/input-consume-token` `{guard: span | bare-token}` — consuming the command token after business success (dispatched by the downstream command surfaces).
Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer, the asynchronous paste matcher. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts).
### The slash pipeline (ui-slash: a root `SlashService` + a per-session `SlashController`)
A trigger/menu/pick pipeline with zero knowledge of "commands":
- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique, registration order = group order = polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in registration order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); it subscribes to the Session, invalidating candidates on projection transitions (a published flip, a Draft workspace change) and calling each source's optional `warm(projection)`; the scope disposer tears it down.
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
### hub / facade: one composer rendered in two places
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation.
- `SessionInputShell` (the facade) is the sole composer implementation; EmptyState is deleted and hero is just a layout state of ConversationRoot: Intent sessions and real sessions ride the same SessionProvider, the central area switches by phase between the hero chrome (HeroShell: hero image + glow + workspace row) and the session view ring, the composer's position in the component tree is constant, and React preserves DOM identity — the same textarea throughout materialize.
- ConversationRoot switches the hero/composer layout class on `composerPhase === 'blank' && (openState === 'open' ¬published)` (a Draft has no host window and openState stays cold, so the criterion must admit an unpublished blank).
- Sending unifies in the hub defaultSink: published → optimistic draft clear + `session.prompt {mode:'queue'}` (backfilled only on failure with no further typing); Draft → `session.connect(workspaceId, text)` (workspace-intent runs materializeIntent first). The hub's `watchTransaction` owns failure backfill: failure backfills only while the draft is empty; a successful retry clears the draft only while it still equals the backfilled text.
- The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee.
### Plain-text references (Decision 21): text outcomes and lexicon decoration
skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface.
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once.
### Per-session provide contributions and the private keyboard surface
- ui-conversation (the hub doubling as a contributor) supplies through `sessions.provide` the `'input'` hook (machine state + the queue overlay) plus the `inputActions` prop (`setDraft`/`submit`, stable void callbacks).
- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary.
### The slot system
The slots around the composer are all session scope, declared by ui-conversation's conversation registration:
- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival.
- `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId.
- `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
- `conversation.composer.dock` — the stats band on the composer's top edge.
- `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions.
- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback.
- `conversation.hero.workspace` (root scope) — the hero-phase workspace picker slot; a pick redirects the Intent through `retargetWorkspace`.
### Testing discipline
The state machine's entire behavior is covered by pure-JS unit tests (event sequences in, asserting state and effects, zero browser DOM); the interaction matrix is projection-tested row by row. This requirement is precisely what forced the pure-core + service-shell layering.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| An ActiveCommand intermediate state / a registerMode mode registry / deriving command mode from the draft | Claims are established explicitly by the pick paths — no table, no derivation |
| Direct bindTarget/bindDraft object wiring | Reverse coupling plus root-singleton cross-session mispairing; scoped bail events preserve dependency inversion with structurally correct routing |
| A unified slash/input-apply, or eventing everything | Three independent payloads cover the cross-plugin rewrites; asynchronous paths stay registry-based explicit calls |
| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract |
| Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable |
| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed |
| The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package |
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
| All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity |
## Consequences
- One composer rendered in two places: hero and in-conversation behavior agree, and materialize preserves textarea DOM identity; EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer.
- The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract.
- Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests.
- Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream.

View File

@@ -0,0 +1,132 @@
# Agent Note: Web 输入状态机、composer 坑位与 slash 管线ui-conversation input / ui-slash
Status: implemented
[English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文
> 范围输入状态机occurrence 表 + claim 看护 + 提交事务、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单管线ui-slash、composer 周边坑位体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)的 sctx / provide / session-maybe 与 blank 实体模型命令知识三型、目录、popup零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)的领地。
## 问题
两个各自为政的 composerheroEmptyState受控链直写 Session与会话内 InputBar普通受控 textarea行为、draft 所有权、发送路径全不一致。要让 `/` 命令、skill 引用、`@` 引用三类触发进入输入面,必须回答:
- 三类触发如何分层,谁对"命令"有知识、谁零知识;
- 输入框如何表达"命令态"——从 draft 文本推导还是显式状态?退格、回车、空格、整行粘贴各是什么语义;
- 提交是异步事务RPC 往返——晚到结果回灌、会话切换、React concurrent 重放如何防御;
- 引用 chip 在纯 textarea 上如何表示undo/剪贴板/粘贴匹配/模型序列化各归谁;
- 跨插件的输入改写菜单回填、引用插入、token 消费)如何做到依赖倒置;
- 无 session → blank session 时哪些 React 外壳必须复用,哪些严格 session 输入体允许替换。
硬约束:组件一律经 slots 挂载;呈现物不进 session log键盘路径全程 IME 安全。
## 决策
### 输入状态机(`InputMachine`
纯状态机,事件进/效果出,注入时钟。四相 phaseplain / adjudicating / claimed / submitting。命令态**永不从 draft 推导**,由 pick 路径在离散时刻显式建立claim 由 `draft.startsWith(token)` 看护、退格破坏自动 releaseclaim 形状 `{token, hint?}`hint 供 ghost text
事件面(`dispatch(ev)` 单写入口,每个事件一个 transaction
- `draft-changed {draft, editRange?}`——textarea 全量草稿editRange 缩小 occurrence 平移计算,缺省前后缀共扫。
- `newline {selection}`——Ctrl+Enter 换行(不经浏览器 execCommand自管 undo 下浏览器写入会分叉双历史)。
- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}`——三个 bail 事件的机器侧span CAS = draftRev 相等。
- `set-invalid {invalidIds}`——owner resolution 结果的样式位(非 transaction
- `undo` / `redo`——自管 transaction log环形 100单字符打字按注入时钟窗合并提交成功清 log
- `paste-begin {text, selection, components?, generation?}`——粘贴 + 热快照同步匹配组件同 transactionUndo 一次回粘贴前);打开 PasteMatchAttempt。
- `paste-upgrade {attemptId, span, reference}`——异步匹配升级为独立 transactionUndo 两段attempt 保持 currentinsertedRange 随升级收缩。
- `invalidate-paste`——DOM 层观察到的 attempt 终结手势caret/selection 操作等)。
- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release`——提交事务平面SubmitAttemptseq + AbortSignal防回灌成功 commit 清稿,失败带漂移守卫 rollback回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice
效果面shell 执行):`adjudicate`(调 SlashController.adjudicate`begin-submit`claim.submit 事务)、`default-sink`普通消息hub 编排)、`notice`
occurrence 表与 chip 三投影:
- 每颗引用在 draft 中占一个 `U+FFFC`;表项 `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`;同名 chip 因 occurrenceId 独立。
- 一切编辑同 transaction 更新 draft 与表:区间平移;与占位符相交的删除/替换作用于整颗。
- 单字符占位使键盘原子性大半原生成立caret 无内部位Backspace/方向键/Shift 扩选原生即整颗);鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。
- 视觉投影 = labelbackdrop 在占位符 offset 渲染 chiptextarea 字形不可见invalid 走失效样式。
- 剪贴板/持久化投影 = clipboardTextcopy/cut 把选区内占位符展开draft 持久化 mirror 写同一投影chat store 里永远是普通文本,刷新 seed 语义 = 全选复制→重开→粘贴chip 跨刷新降级为文本)。
- 模型投影 = submit 时经 source `codec.serialize` 逐颗生成(归 submit attempt 的 signal 与 stale guardowner 缺失/失败/取消则不发送,不降级为 `/name`)。
### 跨插件输入改写:三个 scoped bail 事件
契约声明在 ui-slash依赖最底层生产者经 `sctx.bail(sctx, ...)` 派发,唯一消费侧是 hub 建 shell 时挂在 sctx 上的三个 listener返回 `true` ⟺ 机器过 phase + CAS 守卫并实际改写(发出事件 ≠ 修改成功Space 是否 `preventDefault` 以返回值为准):
- `slash/input-begin-command` `{claim, span}`——菜单 pick / Space 裁决出的命令 claim 回填SlashController 派发)。
- `slash/input-insert-reference` `{reference, span}`——引用 chip 插入SlashController 派发)。
- `slash/input-consume-token` `{guard: span | bare-token}`——业务成功后消费命令 token下游命令面派发
不事件化的调用registry 注册 → 显式调用 → awaitInput 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁scripts/jsdoc.ts
### slash 管线ui-slashroot `SlashService` + per-session `SlashController`
对"命令"零知识的触发/菜单/pick 管线:
- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`(trigger,name) 唯一、注册序 = 组序 = 轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按注册序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步空格在击键中触发只许热缓存matchEnter 异步(可 await 源自身预热,预热失败即 reject
- controller 持有唯一权威 hit含 span菜单关闭后为 Space 保留、per-session menu store、候选 fetch generation、键盘仲裁combobox 模式:焦点始终在 textarea↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行、pick 编排outcome → 自派 bail 事件);每个 session scope 出生时对 source roster 做一次 `warm(projection)`projection 在该 scope 内只有稳定的 sessionId无 published/能力跃迁scope disposer 拆除 controller。
- 触发检测词边界(`user@host`、URL `/` 永不触发、守卫分档plain`/` 到处 + `@` 行内 / claimed`/` 抑制、`@` 活 / frozen全无为冻结纯核。
### hub / facade常驻外壳与严格 session 输入体
- hubtrigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。
- 每个实体 Session 只有一个 `SessionInputShell`facade随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。
- 无 session 时外壳渲染纯展示的 `DisabledInputBar``connectWorkspace` 返回 blank session 后,仅输入体换成严格 session 的 InputBar。这里允许 textarea 重建,`ConversationRoot`、Hero 与布局骨架保持blank → engaging/active 仍是同一 session-bound InputBartextarea 不因 phase 翻转而重建。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell再 open 新 id旧 blank session 留存但不再 current。
- Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清各自独立——拉取不得吞推送对象层推订阅者watchTransaction依赖这一保证。
### 纯文本引用(决策 21text outcome 与 lexicon 装饰
skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draftchip 视觉纯派生:
- PickOutcome 增 `{text}` arm新 scoped bail 事件 `slash/input-insert-text` `{text, span}`与另三个同契约draftRev CAS、返回 true ⟺ 实际改写facade.insertText 走 setDraft 拼接,机器零改动。
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch渲染路径保持同步无副作用controller 聚合为 `lexicon()` 公面。
- `decorations.scanTextRefs`:词边界扫描 draft行首/空白后的 `/name``@name``x/name` 永不命中)对照名录,命中即 `.textRef` markbackdrop 纯 range 高亮,同 hlToken编辑破坏匹配形状下次扫描自然消失。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰legacy `<skill>` 标签 + 纯文本 token
- 旧 occurrence/paste/serialize 链全部保留在盘未删additive删除另成将来一刀。已知局限维持现状粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。
### per-session 供数贡献与键盘私面
- ui-conversationhub 兼贡献者)经 `sessions.provide``'input'` hook机器状态 + queue overlay+ `inputActions` prop`setDraft`/`submit`,稳定 void 回调)。
- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。
### 坑位体系
`conversation` 本身是 session-maybe其会话内容与 composer 输入坑位严格 sessionHero Workspace picker 保持 root。子坑均由 ui-conversation 的 conversation 注册声明:
- `conversation.session`single——严格 session 的 header、view ring 与 chat storesession id 切换时重建。
- `conversation.composer.bar`single——InputBar 本体的坑位InputBar 是真 slot entry自家坑自注册composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。
- `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。
- `conversation.input.dock`——输入上方堆叠条QueueDock 的队列只读列表落此order 定序。
- `conversation.composer.dock`——composer 上沿统计带。
- `conversation.input.left` / `conversation.input.right`——工具行左右区。
- `conversation.input.plan` / `conversation.input.model`single——工具行两具名控制位bar 只传 `locked`owner props空到 owning 插件注册为止,无占位 fallback。
- `conversation.hero.workspace`root scope——无 session / blank Hero 共用的 Workspace pickerpick 经 `connectWorkspace` 复用或创建目标 blank session必要时搬运 draft 后切 current。
### 测试纪律
状态机全部行为由纯 JS 单测覆盖(事件序列进、断言状态与效果,零浏览器 DOM交互矩阵逐行投影测试。这一要求正是纯核 + 服务壳分层的成因。
## Alternatives considered
| 弃案 | 一行理由 |
|---|---|
| ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态 | claim 由 pick 路径显式建立——无表、无推导 |
| bindTarget/bindDraft 对象直连 | 反向耦合 + root 单例跨会话误配scoped bail 事件保依赖倒置且路由结构性正确 |
| 统一 slash/input-apply 或全事件化 | 三个独立 payload 覆盖跨插件改写;异步链路保持 registry 显式调用 |
| contenteditable / 富文本树 | 兼容性差textarea + U+FFFC + occurrence 表覆盖全部交互契约 |
| draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念chip 跨刷新降级可接受 |
| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管 |
| InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 |
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
| 引用一律走 U+FFFC chip决策 21 前旧线) | 纯文本 + 派生装饰零身份状态原文即模型投影undo/剪贴板免特判chip 链保留给需要不可分原子性的场景 |
## 后果
- 一个常驻 conversation 外壳承接 no-session/blank/active无 session → blank 只保证大框架 React identity允许 disabled textarea 替换为严格 InputBar同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。
- 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。
- 提交事务化attempt seq + 漂移守卫使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
- 已知欠账chip 跨刷新保真可复用粘贴匹配未立项subagent 引用的模型表示待业务立项。