DSH / Atlas
2026-06-18implementedfeaturearchived 2026-07-26

Rich ACP bash rendering — the terminal card via the `_meta` convention

富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片

The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. Reference editors rende

English

Problem

The ACP bridge lets each tool own its call rendering via presentCall/presentResult (see tool-call UI presentation and packages/core/tools). For bash we surface the exact command as the tool_call title, the model's description as a content text block, kind: 'execute', and the completed output wrapped in a fenced ```console text block.

Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card.

Key finding: agent-executed terminals use a _meta convention, NOT terminal/create

The ACP spec has a client-side terminal sub-protocol — the agent calls the client's terminal/create with { command, args, cwd, env } and the editor executes the process, then the agent reads terminal/output / wait_for_exit. That model is wrong for us: our harness executes bash itself through dsh-bash (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends.

Studying the two reference agents (2026-06-18) shows neither uses terminal/create for their own shell tool — both keep agent-side execution and emit a _meta convention that Zed special-cases:

  • claude-agent-acp (tools.ts, acp-agent.ts): gated on clientCapabilities._meta.terminal_output. The tool_call carries content: [{ type: 'terminal', terminalId }] and _meta.terminal_info.{ terminal_id, cwd }; output/exit arrive on the tool_call_update's _meta.terminal_output.{ terminal_id, data } and _meta.terminal_exit.{ terminal_id, exit_code, signal }.
  • codex-acp (CodexToolCallMapper.ts, TerminalOutputMode.ts): same terminal_info on the call; output via _meta.terminal_output (full) or _meta.terminal_output_delta (incremental), selected from the same _meta.terminal_output capability.

Zed's side (crates/agent_servers/src/acp.rs, verified): on a ToolCall whose _meta.terminal_info.terminal_id is set, it registers a display-only terminal (header = terminal_info.cwd, label = tool_call.title); on a ToolCallUpdate, _meta.terminal_output.data writes to that terminal and _meta.terminal_exit.{exit_code,signal} sets the status. It advertises the capability as clientCapabilities._meta.terminal_output = true. _meta itself is a spec-blessed ACP extensibility point (typed {[k]: unknown} | null on ToolCall/ToolCallUpdate); the specific keys here (terminal_info/terminal_output/terminal_exit) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side.

Decision

Keep dsh-bash agent-side execution; render the terminal card via the _meta convention, capability-gated, with the ```console text block as the guaranteed fallback.

  1. Capability. initialize reads clientCapabilities._meta.terminal_output and the bridge remembers it per connection.
  2. Neutral presentation vocabulary. dsh-tools gains a terminal-shaped presentation a tool can return — provider-neutral (cwd, the output data, an exitCode/signal), NO ACP types. dsh-tool-bash returns it for bash (cwd from the resolved workdir; output + exit parsed from the run result).
  3. Bridge mapping. When the client advertised the capability, the bridge maps that presentation to: on tool_call, content:[…, {type:'terminal', terminalId}] (any tool content, e.g. the description, rendered BEFORE the terminal block) + _meta.terminal_info.{terminal_id,cwd}; on tool_call_update, _meta.terminal_output.{terminal_id,data} (the captured output) + _meta.terminal_exit.{terminal_id, exit_code|signal} (the parsed exit), with the update's text content OMITTED (an ACP tool_call_update.content REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). terminalId is derived from the harness callId (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ```console text content on the update — unchanged.
  4. The exit pill is parsed from the rendered output; no new execution path, no live streaming. Output is attached at completion (from the agent's own tool/result), not streamed token-by-token. The exit-status pill (_meta.terminal_exit.{exit_code,signal}) IS emitted: the pure presentResult(args, result) seam sees only content blocks, so dsh-tool-bash recovers the structured exit by parsing the status markers ([exit code: N] / [killed by signal: …]) that renderResult appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.

Alternatives considered

  • The ACP client-side terminal sub-protocol (terminal/create) — explicitly rejected: the editor would execute the process, bypassing dsh-bash's env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the _meta convention is the only shape that yields the terminal card while keeping the harness's execution policy.
  • Threading a structured exit through the event schema — rejected in favor of the marker round-trip: the pure presentResult(args, result) seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test.

Consequences

  • Zed-convention _meta keys. The terminal card rides on Zed-specific keys (terminal_info/terminal_output/terminal_exit) inside ACP's spec-blessed _meta extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via _meta.terminal_output), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys.
  • Capability honesty. Emit terminal metadata ONLY when the client advertised _meta.terminal_output; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ```console path.
  • terminalId collisions. Deriving it from the per-call callId keeps it unique within a session and stable across the call/result pair; never reuse one across calls.
  • Exit parsed from rendered text. The exit pill recovers exit_code/signal by parsing renderResult's status markers rather than threading a structured exit through the event schema (which the pure presentResult seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead.
  • Provider-neutral vocabulary creep. The terminal presentation widens the dsh-tools surface; keep it neutral (no ACP types leak into dsh-tools) and only as rich as a second UI consumer would also want.

Out of scope / non-goals

The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own Agent Note when someone takes them on: live incremental streaming (_meta.terminal_output_delta as chunks arrive, which needs an incremental-output seam on dsh-bash), and command classification (parsing a cat/sed as a read card with a file location, a grep as a search, etc., falling back to the terminal card — display-only, must never change what executes).

中文

问题

ACP(Agent Client Protocol)桥接层允许每个工具通过 presentCall/presentResult 自行控制调用渲染(见工具调用 UI 呈现packages/core/tools)。对于 bash,我们将确切命令作为 tool_call 标题呈现,模型的 description 作为一个内容文本块,kind: 'execute',完成后的输出包裹在 ```console 围栏文本块中。

参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。

关键发现:agent 执行的终端使用 _meta 约定,而非 terminal/create

ACP 规范有一个客户端侧终端子协议:agent(智能体)调用客户端的 terminal/create(传入 { command, args, cwd, env }),由编辑器执行进程,然后 agent 读取 terminal/output / wait_for_exit。这个模型不适合我们:我们的 harness 通过 dsh-bash 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。

研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 terminal/create——两者都保持 agent 侧执行,并发出一套 _meta 约定,由 Zed 特殊处理:

  • claude-agent-acptools.tsacp-agent.ts):以 clientCapabilities._meta.terminal_output 为门控。tool_call 携带 content: [{ type: 'terminal', terminalId }]_meta.terminal_info.{ terminal_id, cwd };输出和退出通过 tool_call_update_meta.terminal_output.{ terminal_id, data }_meta.terminal_exit.{ terminal_id, exit_code, signal } 到达。
  • codex-acpCodexToolCallMapper.tsTerminalOutputMode.ts):调用上同样携带 terminal_info;输出通过 _meta.terminal_output(完整)或 _meta.terminal_output_delta(增量),由同一个 _meta.terminal_output 能力选择。

Zed 侧(crates/agent_servers/src/acp.rs,已验证):收到 ToolCall 且其 _meta.terminal_info.terminal_id 已设置时,注册一个仅展示的终端(header = terminal_info.cwd,label = tool_call.title);收到 ToolCallUpdate 时,_meta.terminal_output.data 写入该终端,_meta.terminal_exit.{exit_code,signal} 设置状态。客户端通过 clientCapabilities._meta.terminal_output = true 声明此能力。_meta 本身是 ACP 规范认可的扩展点(在 ToolCall/ToolCallUpdate 上类型为 {[k]: unknown} | null);这里的具体键terminal_info/terminal_output/terminal_exit)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。

决策

保持 dsh-bash 的 agent 侧执行;通过 _meta 约定渲染终端卡片,以能力声明为门控,以 ```console 文本块作为保底回退。

  1. 能力声明。 initialize 读取 clientCapabilities._meta.terminal_output,桥接层按连接记住它。
  2. 提供方无关的展示词汇。 dsh-tools 新增一种终端形态的展示结构,工具可返回它——提供方无关(cwd、输出 dataexitCode/signal),不含 ACP 类型。dsh-tool-bashbash 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。
  3. 桥接映射。 当客户端声明了该能力时,桥接层将展示结构映射为:在 tool_call 上,content:[…, {type:'terminal', terminalId}](工具的任何 content,如描述,渲染在终端块之前)+ _meta.terminal_info.{terminal_id,cwd};在 tool_call_update 上,_meta.terminal_output.{terminal_id,data}(捕获的输出)+ _meta.terminal_exit.{terminal_id, exit_code|signal}(解析后的退出),且 update 的文本 content 被省略(ACP 的 tool_call_update.content 会替换调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。terminalId 由 harness 的 callId 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ```console 文本内容——行为不变。
  4. 退出信息从渲染输出中解析;无新执行路径,无实时流式传输。 输出在完成时附加(来自 agent 自身的 tool/result),不逐 token 流式传输。退出状态(_meta.terminal_exit.{exit_code,signal})确实会发出:纯 presentResult(args, result) seam 只能看到内容块,因此 dsh-tool-bash 通过解析 renderResult 追加的状态标记([exit code: N] / [killed by signal: …])来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。

曾考虑的替代方案

  • ACP 客户端侧终端子协议(terminal/create:明确否决。编辑器将执行进程,绕过 dsh-bash 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 _meta 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
  • 通过事件 schema 传递结构化退出信息:否决,改用标记往返方案。纯 presentResult(args, result) seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。

后果

  • Zed 约定的 _meta 键。 终端卡片依赖 Zed 特有的键(terminal_info/terminal_output/terminal_exit),位于 ACP 规范认可的 _meta 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 _meta.terminal_output 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。
  • 能力诚实。 仅在客户端声明了 _meta.terminal_output 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ```console 路径。
  • terminalId 冲突。 从每次调用的 callId 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。
  • 退出信息从渲染文本解析。 退出信息通过解析 renderResult 的状态标记恢复 exit_code/signal,而非通过事件 schema 传递结构化退出(纯 presentResult seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。
  • 提供方无关词汇的蔓延。 终端展示结构扩大了 dsh-tools 的接口面;保持其中立性(不让 ACP 类型泄漏到 dsh-tools),且只提供第二个 UI 消费方同样需要的丰富度。

超出范围 / 非目标

文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:实时增量流式传输(在分片到达时发出 _meta.terminal_output_delta,需要在 dsh-bash 上新增增量输出 seam);命令分类(将 cat/sed 解析为带文件位置的 read 卡片,将 grep 解析为 search,回退到终端卡片——仅展示,绝不改变实际执行内容)。