research phase 9 w9 composer controls - sinrimin/harness-nexus GitHub Wiki
Status: research complete 2026-09-11, implementation NOT started. Feeds the W8 design doc's "Deferred" table (
design-phase-9-w8-sender.md) — the four composer controls that each need a wire arm first. This doc is the empirical ground truth + the proposed wire plan; the design doc for the implementation wave should be written against it.
Sources verified (all local / pinned):
| Source | Version | Where |
|---|---|---|
@agentclientprotocol/claude-agent-acp (CC wrapper) |
0.76.0 | rig machine npx cache (our shipped default) |
@zed-industries/codex-acp |
0.16.0 | npm tarball + GitHub main source |
| dsh ACP adapter | 0.1.2-rc.1 |
~/acp-ref/dsh/packages/acp/acp/src/ (official monorepo checkout = rig version) |
@agentclientprotocol/sdk schema |
wrapper 0.76's bundled SDK | npx cache dist/schema/types.gen.d.ts
|
| portal reference (UI logic) | snapshot 2026-09-10 |
~/acp-ref/portal/src/components/{ChatInput,SessionConfigBar}.tsx + hooks/useAcpConnection.ts
|
-
模式 / 模型 / 思考级别不需要发明任何协议 — ACP 标准已有整套
"session modes & configuration":
session/new|load|resume响应携带modes+configOptions,切换走session/set_mode/session/set_config_option,变更推current_mode_update/config_option_update。三个在用适配器(claude wrapper 0.76、codex-acp 0.16、dsh 0.1.2-rc.1)全部实现(dsh 无 set_mode —— 无权限模式概念)。 我们缺的只是把这条链路接到自己的 wire 上。 -
附件分两类:图片是 ACP 一等公民(
{type:'image', data, mimeType}base64 内联块,三适配器都收),文件引用走resource_link(我们的promptBlockSchema已经有这个 variant 且 daemon 原样转发 —— 纯 UI 缺 口)。音频/嵌入式 blob 资源三适配器都忽略,不做。 - 建议实现顺序:A. session-config 选择器(mode/model/effort)→ B. 图片 附件 → C. 文件引用(@ mention + workspace picker),各自独立可发。
SDK types.gen.d.ts(协议文档 agentclientprotocol.com/protocol/session-modes):
// 建立响应(session/new | load | resume 都带,均可空)
modes?: { currentModeId: string; availableModes: { id, name, description?, _meta? }[] }
configOptions?: SessionConfigOption[]
SessionConfigOption = {
id: string // 'mode' | 'model' | 'effort' | 'reasoning_effort' | …
name: string // 展示名
description?: string
category?: 'mode' | 'model' | 'model_config' | 'thought_level' | (string)
type: 'select' | 'boolean'
currentValue?: string // select 的当前值(OPAQUE)
options?: { value, name, description?, group?, _meta? }[] // group = 分组(dsh 按 provider)
_meta?: …
}
// 切换(客户端 → agent 的普通 JSON-RPC 请求)
'session/set_mode' { sessionId, modeId }
'session/set_config_option' { sessionId, configId, value } // select: string;boolean: boolean
// 推送(session/update 的一种)
{ sessionUpdate: 'current_mode_update', currentModeId }
{ sessionUpdate: 'config_option_update', configOptions: SessionConfigOption[] }要点:
-
option 的
value是 OPAQUE 字符串。dsh 的 model value 是JSON.stringify([provider, model])—— 客户端只做相等比较展示,绝不能 解析或自拼。 - 权限卡不能切模式:
PermissionOptionKind只有allow_once|allow_always|reject_once|reject_always,响应只回optionId(wrapper 内部的updatedPermissions是 Claude SDK 内部事,不上 ACP 线)。 我们acpPermissionOptionSchema的四种 kind 是对的,不用动。 -
initialize响应的agentCapabilities.promptCapabilities.{image,audio, embeddedContext}是客户端判断附件按钮可不可用的依据。
| 能力 | claude wrapper 0.76.0 | codex-acp 0.16.0 | dsh-acp 0.1.2-rc.1 |
|---|---|---|---|
modes + set_mode
|
✅ default(Manual)/acceptEdits/plan/auto(+bypassPermissions 仅当非 root 或 IS_SANDBOX;dontAsk 可解析但不进列表) |
✅ read-only/auto/full-access(approval+sandbox preset;untrusted 项目显示 read-only 以便信任升级) |
❌ 无(权限模式不上 ACP) |
| mode 也作为 configOption | ✅ id=mode
|
✅ id=mode(名 "Approval Preset") |
❌ |
| model 选择 | ✅ id=model,来自 SDK model infos(含 context hint 归一化) |
✅ id=model,来自 models_manager presets(show_in_picker 过滤) |
✅ id=model,provider 分组(group 字段),value=JSON [provider,model]
|
| 思考级别 | ✅ id=effort,category thought_level,选项 = 当前模型 supportedEffortLevels + default 行;持久化到 settings(per-model modelSettings) |
✅ id=reasoning_effort,仅当 preset 支持 >1 档 |
✅ id=reasoning_effort,按当前模型动态(llm.resolveModelInfo().reasoning.efforts),'' = Provider default |
| 切换后推送 | ✅ config_option_update / current_mode_update
|
✅ | ✅(另有 topology 变化自发推送) |
| 换 model 的联动 | 重建 effort 选项;auto 模式在模型不支持时回落 acceptEdits 并提示 |
— |
按 turn 钉死:prompt 入场时 snapshot selection,切换只影响下一 turn |
| 图片 prompt | ✅ base64 + http(s) URL 两种 | ✅ base64(转 data URL) | ✅ 仅 base64,png/jpeg/webp/gif,canonical base64 严格校验且要求当前路由支持图片(否则 invalid_params) |
promptCapabilities |
{image:true, embeddedContext:true} 静态 |
{image:true, embeddedContext:true} 静态 |
{image: 动态}(按 provider+model 探测)+ audio:false
|
| resource_link(文件引用) | → [@name](uri) 文本链接(agent 自己用 Read 工具取) |
→ [@name](uri) 文本 |
→ [resource_link name=… uri=…] 文本标记(最弱:不会自动读) |
| resource(text) 嵌入 | ✅ <context ref=…> 注入 |
✅ 同 | ❌ 不收 |
| 音频 / resource(blob) | 忽略 | 忽略 | 拒绝/忽略 |
模式语义备忘(UI 文案用):
- claude:
default="Manual"(总要询问)、acceptEdits(自动接受文件编辑)、plan(先出计划)、auto(Claude 自己决定权限)、bypassPermissions(全放行,root 下默认不出现 —— 数据驱动渲染即可,我们不用自己挡)。 - codex:
read-only/auto(workspace 沙箱)/full-access(:danger-no-sandbox)。 - 危险方向(auto/full-access/bypass)建议 confirm-first(W8 设计里已有此 结论:削弱门禁近似破坏性操作)。
SessionConfigBar.tsx + useAcpConnection.ts:
- 数据从 session/new/load/resume/fork 响应读
modes+configOptions; 三个 select 按category定位(model/thought_level/mode), mode 项缺失时回退用modes.availableModes构造。 - 切换 = 发
session/set_config_option/session/set_mode,乐观更新本地 state,agent 的响应/推送再确认或纠正(config_option_update全量替换 options、current_mode_update只改 currentModeId)。 - effort 有个 trick:会话建立时检测并一次性切到 max(Anthropic 不持久化
max,响应总是 "default")。
⚠️ 不抄 —— 这是该产品的偏好,不是通用行 为;我们展示真实值即可。 - @ 文件引用是
FileMentionPopover(固定定位浮层 + 光标触发),发送的是 resource_link;没有图片/上传附件 UI(所以参考项目无附件逻辑)。
已具备(比预想多):
-
promptBlockSchema(shared/realtime.ts)已含text+resource_link两 variant;daemonrunPrompt把 blocks 原样发给session/prompt—— 文件引用今天端到端就是通的,只缺 UI。 - socket.io
maxHttpBufferSize已是 8MB(SOCKET_MAX_HTTP_BUFFER)—— base64 图片放得下(单图 ≤4MB 原图 ≈5.4MB base64)。 - 权限卡 optionId 逐字透传 + 四种 kind —— 与现行协议一致,无需动。
缺口(按层):
| 层 | 现状 | 需要 |
|---|---|---|
| shared |
promptBlockSchema 无 image variant;chatStreamEventSchema 无 config 类事件;ready schema 无 caps |
+image 块(带尺寸上限);+session_config 事件(modes+configOptions 全量快照,last-wins);+chat:config.set 请求(mode/option 二合一或两个);ready +promptCapabilities
|
| daemon | session/new/load/resume 响应只读 sessionId;mapAcpUpdate 对 current_mode_update/config_option_update 落到 raw;initialize 不捕获 promptCapabilities |
捕获 modes/configOptions → ready 事件带上 + 发一条 session_config 流事件(进 history ring,resync 免费回放);两个 update 映射为 session_config(合并后全量推);chat:config.set → conn.request('session/set_mode'/'session/set_config_option');initialize 捕获 agentCapabilities.promptCapabilities
|
| server | 无对应 /app handler;onStream 校验白名单需要新事件 kind |
/app chat:config.set(校验 + 转发 /ctl,owner 门禁同其它 chat handler);新事件 kind 放行 |
| web | fold 的 user 行是纯文本({row:'user', text},resource_link 折成 [name]);composer 无附件/选择器控件 |
fold user 行改 blocks 模型(文本 + 图片缩略图 chip + 文件 chip);composer + 菜单(图片上传/文件引用)+ mode/model/effort 三个 select(数据驱动,idle 时才可切) |
wire(一次加齐):
- shared:
sessionModeStateSchema/sessionConfigOptionSchema(镜像 ACP 形状,尺寸上限:≤32 options、字符串 ≤256 等);chatStreamEventSchema加{kind:'session_config', modes?, configOptions?};浏览器→serverchat:config.set {sessionId, modeId?} | {sessionId, configId, value};chatSessionReadyEventSchema加promptCapabilities(后续 B 用)。 - daemon:establish 响应读取 modes/configOptions(new/load/resume 三臂)→
ready +
session_config事件(push 进 history ring,resync/刷新即恢 复选择器状态);current_mode_update/config_option_update→ 合并出全量session_config;chat:config.sethandler → 转发 ACP 请求(错误走 ack)。 - server:
/apphandler 校验转发(owner-only,同 message.send 的门禁形 状);onStream放行新 kind。 - web:fold state +
config(last-wins);composer 工具栏左侧三个小 select (或 chip+popover):-
数据驱动渲染:
category==='mode'(或 modes 回退)→ 权限 chip;category==='model'→ 模型;category==='thought_level'→ 思考级别。 没有 就不渲染(dsh 无 mode chip;effort 依模型动态出现)。 - 选项名/描述直接用 agent 给的(英文原样,符合 i18n 规则里 wire-value-英文 的例外 —— mode/model id 是协议值;也可加 id→本地化 label 映射表作增强)。
- value 当 OPAQUE key 用;dsh 的分组用
<Select group>或分组 label。 -
turnActive 时禁用(dsh 按 turn 钉 selection;也避开 busy 竞态),
乐观更新 +
session_config推送纠正(portal 同款)。 - 危险模式(id ∈ {bypassPermissions, full-access, auto})confirm-first。
- 与 W3 RuntimeConfig 的关系:正交可组合 —— W3 是机器级默认(落 settings/config.toml/settings.yaml),会话内切换只影响本 channel 的后 续 turn;新会话仍从机器默认出发。resume 的会话 currentValue 反映其钉 死的模型(W7 的 stale-model 标注逻辑不受影响)。
-
数据驱动渲染:
工作量估计:shared+daemon+server ~1 天(含测试),web ~1 天。
- shared:
promptBlockSchema+{type:'image', data: base64(≤6MB), mimeType: enum(png/jpeg/webp/gif)};chatMessageSendRequestSchema的 总载荷上限校验(≤6MB/turn,≤4 图)。 - daemon:runPrompt 已原样转发 —— 仅需 schema 放行;dsh 拒绝时 invalid_params 文案透传给 ack。
- web:
- composer
+菜单 → 图片上传(file input accept 四种 mime)+ 粘贴- 拖拽(截图流的主路径是粘贴)。
- 客户端先行压缩/限边(canvas 长边 ≤1568px、质量 0.85 重编码)—— 把 wire 控制在数百 KB,而不是指望 8MB 缓冲;服务端仍设硬上限兜底。
- draft 状态加附件 chips(缩略图 + 移除);fold user 行改 blocks 模型渲染 缩略图(点击放大用现有 lightbox 模式或新 Dialog)。
- 可用性门禁:ready 的
promptCapabilities.image === true才启用+图 片项(dsh 动态、可能为 false —— UI 如实禁用并提示)。
- composer
- 历史回放:user blocks 已进 history ring —— 图片以 base64 回放会吃内存
(ring ≤2000 项);dsh 的 transcript 历史不含用户图片(dsh 落盘自己的
attachment 词汇),claude 的 load 重放
user_message_chunk也只回文本块 —— 可接受:历史里图片位置显示占位 chip 即可,不追求回放原图。
- wire 已通(resource_link 原样到 agent)。纯 UI:
@触发 popover,数据源 复用 W6 的workspace:list(daemon 已有、按 cwd 一层懒加载 —— dir-picker 同款);选中插入{type:'resource_link', name: basename, uri: file://<abs>}。 - 渲染:用户行/流里的 resource_link 显示
[@name]mono chip(fold 现有[${b.name}]文本折法升级为 chip)。 - 预期管理:claude/codex 会把链接交给 agent 自己读(效果最好);dsh 只是文 本标记 —— 文档里写明。
- 音频附件、resource(blob) 上传(三适配器全忽略)。
- 嵌入式 text resource 上传(只有 claude/codex 收,dsh 拒绝 —— 等有需求再 论,走 C 的 @ 引用已够)。
- codex
dontAsk、claude wrapper 的providers/list|set|disable(client 托管 LLM 路由 —— 未来可替代 W3 的 settings 写入,另立课题)。 - portal 的 effort 自动 max trick(产品偏好,不通用)。
-
dsh model 值的 OPAQUE 性:任何想"显示 provider/model 分列"的冲动都
要克制 —— 用 option 的
group/name展示,value 只做 key。 - ring 内存:config 事件全量快照(model 列表 ~几十项)每次变化整包推 —— 频率低(用户切换/模型拓扑变化),可接受;若 dsh 频繁推再加去抖。
- 权限模式与 C6:mode 是会话内削弱门禁的开关;机器级"允许的最低模式" 策略留给 C6 权限策略工作(参考 bridge 的 policy-engine 形状)。
-
图片大小上限与
SOCKET_MAX_HTTP_BUFFER(8MB)联动:文档里写清 "单 turn 总量 ≤6MB"的由来(base64 膨胀 1.35x + 余量)。 - claude wrapper 的 model display 归一化(context hint
[1m]后缀等)—— 我们只展示name,不碰 value,天然规避。