diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml
new file mode 100644
index 0000000000..0addad9e97
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-19-plugin-command-registration.md: a207c6257bd4e9e4013f1abb661dd967ed2a52dc
+2026-07-19-plugin-command-registration.zh.md: e21d187ded0ee0f4daa370655994eb306fb1c5fd
diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md
new file mode 100644
index 0000000000..a207c6257b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md
@@ -0,0 +1,81 @@
+# Agent Note: Plugin-owned human command registration
+
+Status: implemented
+
+English | [中文](2026-07-19-plugin-command-registration.zh.md)
+
+## Problem
+
+The TUI owns seven slash commands, while ACP defines a standard command catalog and invocation shape. Keeping command names, help text, autocomplete, dispatch, and cancellation inside each adapter makes every new command an adapter edit, prevents optional plugins from contributing commands, and lets the two front doors drift. Treating slash input as an ordinary model prompt is also unsafe: a user-visible direct action can unexpectedly consume tokens or let the model reinterpret an unknown command.
+
+A shared mechanism must remain a UI concern rather than a model tool or agent-loop branch. It also needs exact per-agent visibility, HMR-safe removal, per-session ACP discovery, direct result rendering, and request-scoped cancellation without adding command text or output to model history.
+
+## Decision
+
+`@deepseek-ai/dsh-commands` in `packages/ui/commands/` is the product command registry. The terminal and ACP app bundles mount it beside their consuming front door, and the SDK project helper emits the same service when scaffolding ACP directly; the executor-less, UI-less agent spine remains independent. TUI and ACP inject the service, while command producers depend only on the registry and any domain they operate.
+
+### Registry contract
+
+A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, and an abortable handler. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. Every adapter consuming the registry sees every effective definition; a command plugin that cannot operate in a deployment omits its registration there instead of encoding adapter identities in the shared domain.
+
+`list(agent)` returns immutable name-sorted descriptors after scoped shadowing. `find(agent, name)` resolves the effective definition. `execute(agent, line, signal)` parses and runs a known definition, returning a detached `success` or `error` result; invalid syntax and unknown names return `undefined` so the adapter owns its direct error text.
+
+`parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision.
+
+### Scope and lifecycle
+
+An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph.
+
+Registration and removal emit the unfiltered, non-vetoing `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. The registry contains and logs each observer failure independently, so a broken UI refresh cannot roll back another plugin's mutation or starve a later observer. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers.
+
+### Direct dispatch and cancellation
+
+Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started.
+
+Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state.
+
+### TUI mapping
+
+The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`.
+
+Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown.
+
+### ACP mapping
+
+The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; a new session's RPC response introduces its server-generated id before the snapshot is enqueued. Every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`.
+
+ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`.
+
+One model prompt or direct command may be in flight per ACP session, independently across sessions. `session/cancel` aborts the direct command when one owns the request; it calls `Agent.cancel()` only for an agent prompt, so cancelling a command cannot destroy unrelated queued or injected agent work. Connection teardown aborts commands and then disposes the owned agents.
+
+## Testing
+
+The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage.
+
+TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes.
+
+## Alternatives considered
+
+- **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing every front door.
+- **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation.
+- **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly.
+- **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead.
+- **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment.
+- **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes.
+- **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events.
+- **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation.
+
+## Consequences
+
+- Command producers are ordinary removable plugins, and TUI/ACP share one validated catalog and dispatch contract.
+- Agent-specific definitions retain existing flat scope and shadow semantics without a core-to-UI dependency.
+- Unknown slash input and command output are deterministic UI behavior with zero direct model tokens.
+- ACP clients receive current per-session snapshots after creation, load, registration, and HMR removal.
+- Direct command cancellation is isolated from model-turn cancellation.
+
+## Known limitations and deferred work
+
+- Input metadata is ACP's current unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later protocol extension.
+- Generic command output is live-only and is not reconstructed after TUI restart or ACP reconnect.
+- Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal.
+- The headless CLI and JSON-RPC SDK front doors do not expose the command plane; only TUI and ACP consume it.
diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md
new file mode 100644
index 0000000000..e21d187ded
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md
@@ -0,0 +1,81 @@
+# Agent Note: 插件拥有的人类命令注册
+
+Status: implemented
+
+[English](2026-07-19-plugin-command-registration.md) | 中文
+
+## 问题
+
+TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形态。如果命令名、帮助文本、自动补全、分派和取消都留在各适配器内部,每个新命令都需要修改适配器,可选插件无法贡献命令,两个前端也会逐渐偏离。把斜杠输入当作普通模型提示同样不安全:用户可见的直接操作可能意外消耗 token,或让模型重新解释未知命令。
+
+共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确的逐智能体可见性、可安全 HMR 移除、逐会话 ACP 发现、直接结果渲染和请求作用域取消,同时不得把命令文本或输出加入模型历史。
+
+## 决策
+
+位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。终端与 ACP 应用 bundle(组合包)把它挂载在消费该服务的前端旁,SDK 项目 helper(辅助器)在直接搭建 ACP 时也会生成同一服务;无执行器、无 UI 的智能体 spine(主干)保持独立。TUI 与 ACP 注入该服务,命令生产者只依赖注册表及其操作的领域。
+
+### 注册表契约
+
+`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费该注册表的适配器都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把适配器身份编码进共享领域。
+
+`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。
+
+`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。
+
+### 作用域与生命周期
+
+无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。
+
+注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。
+
+### 直接分派与取消
+
+命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。
+
+预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。
+
+### TUI 映射
+
+TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。
+
+每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。
+
+### ACP 映射
+
+桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;新会话的 RPC 响应会先引入服务端生成的 id,随后快照才会入队。每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。
+
+ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。
+
+每个 ACP 会话同时只能有一个模型提示或直接命令进行中,各会话彼此独立。当直接命令拥有请求时,`session/cancel` 会中止它;只有智能体提示才调用 `Agent.cancel()`,因此取消命令不会销毁无关的排队或注入智能体工作。连接清理会先中止命令,再释放所拥有的智能体。
+
+## 测试
+
+注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。
+
+TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。
+
+## 考虑过的替代方案
+
+- **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改每个前端。
+- **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。
+- **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。
+- **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。
+- **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。
+- **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。
+- **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。
+- **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。
+
+## 后果
+
+- 命令生产者是普通的可移除插件,TUI 与 ACP 共享一个经过校验的目录和分派契约。
+- 智能体特定定义保留现有扁平作用域与遮蔽语义,不引入核心到 UI 的依赖。
+- 未知斜杠输入与命令输出是确定性 UI 行为,直接模型 token 成本为零。
+- ACP 客户端在创建、加载、注册和 HMR 移除后收到当前的逐会话快照。
+- 直接命令取消与模型轮次取消彼此隔离。
+
+## 已知限制与延期工作
+
+- 输入元数据仅为 ACP 当前的非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续协议扩展。
+- 通用命令输出仅实时存在,TUI 重启或 ACP 重新连接后不会重建。
+- 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。
+- 无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 和 ACP 消费它。
diff --git a/docs/architecture.md b/docs/architecture.md
index f3bebda3fc..25fd1a5105 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -150,35 +150,36 @@ Streaming uses raw chunks and `BlockAssembler`. One `LlmAdapter.stream()` is one
### Capability Pattern
-A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family.
+A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family.
-Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
+Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths.
### Bundles And Apps
-`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
+`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal and human-command registry; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC and the same command registry ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
### Where New Behavior Goes
-New behavior should attach to a documented extension point; changing the shipped loop requires updating this map.
+New behavior attaches to a documented extension point; a loop change updates this map.
| Goal | Mechanism |
|---|---|
| Add a model provider | register an adapter on `ctx.llm` |
-| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
-| Add command execution | implement and register a `ctx.bash` backend |
-| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it |
+| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly |
+| Add shell execution | implement and register a `ctx.bash` backend |
+| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn |
+| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it |
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
-| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop |
-| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header |
+| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop |
+| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
-| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) |
+| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) |
The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index d7152ec468..8dfe27b5f8 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -48,6 +48,8 @@ flowchart LR
pkg_user_interaction["user-interaction"]
svc_userInteraction["ctx.userInteraction
Human question/answer seam"]
pkg_tui["tui"]
+ pkg_commands["commands"]
+ svc_commands["ctx.commands
Human command registry"]
pkg_skill["skill"]
svc_skills["ctx.skills
Skill provider registry"]
pkg_skill_local["skill-local"]
@@ -113,6 +115,7 @@ flowchart LR
pkg_bash_sandbox --> svc_bash
pkg_code_runtime --> svc_codeRuntime
pkg_code_runtime_worker --> svc_codeRuntime
+ pkg_commands --> svc_commands
pkg_compact --> svc_compact
pkg_compact_basic --> svc_compact
pkg_compact_tool_result_prune --> svc_toolResultPrune
@@ -168,6 +171,8 @@ flowchart LR
svc_bash --> pkg_hooks_codex
svc_bash --> pkg_tool_bash
svc_codeRuntime --> pkg_tools
+ svc_commands --> pkg_acp
+ svc_commands --> pkg_tui
svc_compact --> pkg_compact_basic
svc_fs --> pkg_tool_fs
svc_llm --> pkg_agent_loop
@@ -230,6 +235,7 @@ flowchart LR
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
+| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 08bd7d5413..9e40697e5c 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml`
## `@deepseek-ai/dsh-acp`
-Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt`
+Requires: `agents` · `commands` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt`
```ts config-catalog
/** Plugin config: the agent template ACP sessions are created from. */
@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
-Source: [`packages/ui/acp/src/index.ts:207`](../packages/ui/acp/src/index.ts)
+Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
@@ -75,7 +75,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
-Source: [`packages/examples/acp-demo/src/index.ts:36`](../packages/examples/acp-demo/src/index.ts)
+Source: [`packages/examples/acp-demo/src/index.ts:37`](../packages/examples/acp-demo/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -1273,7 +1273,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde
## `@deepseek-ai/dsh-tui`
-Requires: `agents` · `userInteraction` · `tools`
+Requires: `agents` · `commands` · `userInteraction` · `tools`
```ts config-catalog
/** Serializable plugin configuration. */
@@ -1305,7 +1305,7 @@ export interface TuiConfig {
}
```
-Source: [`packages/ui/tui/src/index.ts:102`](../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:103`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`
@@ -1349,7 +1349,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
-Source: [`packages/examples/tui-demo/src/index.ts:31`](../packages/examples/tui-demo/src/index.ts)
+Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
@@ -1546,6 +1546,7 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont
These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 9af8af1335..a213c9e11a 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -411,6 +411,24 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques
Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts)
+## `commands/*`
+
+### `commands/change` — emit
+
+A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.
+
+```ts cordis-catalog
+/**
+ * A command was registered or unregistered. This is an unfiltered registry
+ * notification because a global or scoped change may affect any UI view.
+ * Observer failures are contained and cannot veto the registry mutation.
+ * @mode emit
+ */
+'commands/change'(): void
+```
+
+Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts)
+
## `fs/*`
### `fs/edit-intent` — waterfall
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index ef3914d2fe..5fa14917e1 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -340,6 +340,47 @@ Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResu
Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts)
+## `ctx.commands` — `CommandService`
+
+Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent.
+
+```ts cordis-catalog
+/**
+ * Register a global or calling-agent-scoped command.
+ * @param definition - discovery metadata and direct UI handler.
+ * @returns the exact effect disposer that unregisters this definition.
+ */
+register(definition: CommandDefinition): () => void
+
+/**
+ * List the effective immutable command descriptors for one agent.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @returns name-sorted descriptors after scoped shadowing.
+ */
+list(agent: Agent): readonly CommandDescriptor[]
+
+/**
+ * Resolve one effective command definition.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @param name - command name without a slash.
+ * @returns the scoped shadow or global definition.
+ */
+find(agent: Agent, name: string): CommandDefinition | undefined
+
+/**
+ * Parse and execute a known command without sending it to the model.
+ * @param agent - exact receiving agent.
+ * @param line - complete slash-command line.
+ * @param signal - cancellation signal owned by the UI request.
+ * @returns a detached result, or `undefined` when syntax or name does not resolve.
+ */
+async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise
+```
+
+Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md)
+
+Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts)
+
## `ctx.compact` — `CompactService` (abstract seam)
Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`.
diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md
new file mode 100644
index 0000000000..c33b27ce1c
--- /dev/null
+++ b/docs/core-data-structures/commands.md
@@ -0,0 +1,84 @@
+# Human Commands
+
+The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and ACP adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations.
+
+Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
+
+## Input metadata
+
+ACP currently exposes one unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
+
+```ts type-equiv
+/** Immutable command input metadata compatible with ACP unstructured input. */
+interface CommandInputDescriptor {
+ /** Placeholder shown before the user supplies free-form input. */
+ readonly hint: string
+}
+```
+
+## Definition
+
+`CommandDefinition` is the plugin-authored registration. The registry validates and freezes a detached effective definition.
+
+```ts type-equiv
+/** Plugin-owned command registration. */
+interface CommandDefinition {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+ /** Execute against the receiving agent without sending the command to the model. */
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise
+}
+```
+
+## Invocation and result
+
+The adapter owns cancellation and passes the exact target agent. `rawInput` begins immediately after the parsed name and retains the adapter-delivered separator and suffix. Results are direct UI outcomes, not tool results or session events.
+
+```ts type-equiv
+/** Invocation passed to one registered command handler. */
+interface CommandInvocation {
+ /** Exact agent whose human-facing surface received the command. */
+ readonly agent: Agent
+ /** Exact text following the registered command name, including separator whitespace. */
+ readonly rawInput: string
+ /** Cancellation signal owned by the dispatching UI request. */
+ readonly signal: AbortSignal
+}
+```
+
+```ts type-equiv
+/** Expected command outcome rendered directly by the dispatching UI. */
+type CommandResult =
+ | { readonly kind: 'success'; readonly text?: string }
+ | { readonly kind: 'error'; readonly text: string }
+```
+
+## Discovery and parsing views
+
+Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command.
+
+```ts type-equiv
+/** Handler-free immutable command view returned to UI adapters. */
+interface CommandDescriptor {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+}
+```
+
+```ts type-equiv
+/** Syntactically valid slash command before registry resolution. */
+interface ParsedCommand {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Exact text following the command name. */
+ readonly rawInput: string
+}
+```
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index deca0aad69..dfdc258cb0 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions |
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
| [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution |
+| [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces |
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index f9d2e8e948..99acf07d24 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
+| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
diff --git a/docs/glossary.md b/docs/glossary.md
index c8ec6f378d..e38c1f48b9 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -22,6 +22,11 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i
- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap.
- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work.
+## human command
+
+- **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`.
+- **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain.
+
## loop hierarchy
- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 545dc35777..928e13119d 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -112,6 +112,7 @@ flowchart TD
subgraph group_ui["packages/ui"]
pkg_acp["acp"]
pkg_app_boot["app-boot"]
+ pkg_commands["commands"]
pkg_jsonrpc["jsonrpc"]
pkg_permission["permission"]
pkg_tool_ask_user["tool-ask-user"]
@@ -241,6 +242,8 @@ flowchart TD
pkg_invariants --> pkg_llm
pkg_invariants --> pkg_scope
pkg_invariants --> pkg_session
+ pkg_commands --> pkg_agent
+ pkg_commands --> pkg_scope
pkg_user_approval --> pkg_agent
pkg_user_approval --> pkg_brand
pkg_user_approval --> pkg_llm
@@ -361,6 +364,7 @@ flowchart TD
pkg_agent_loop_testkit --> pkg_tools
pkg_acp --> pkg_agent
pkg_acp --> pkg_bash
+ pkg_acp --> pkg_commands
pkg_acp --> pkg_llm
pkg_acp --> pkg_llm_retry
pkg_acp --> pkg_permission
@@ -424,6 +428,7 @@ flowchart TD
pkg_jsonrpc --> pkg_subagent
pkg_tui --> pkg_agent
pkg_tui --> pkg_agent_loop
+ pkg_tui --> pkg_commands
pkg_tui --> pkg_llm
pkg_tui --> pkg_llm_retry
pkg_tui --> pkg_session
@@ -461,6 +466,7 @@ flowchart TD
pkg_acp_demo --> pkg_acp
pkg_acp_demo --> pkg_agent_spine_demo
pkg_acp_demo --> pkg_app_boot
+ pkg_acp_demo --> pkg_commands
pkg_acp_demo --> pkg_session_persistence_jsonl
pkg_acp_demo --> pkg_tools
pkg_acp_demo --> pkg_user_interaction
@@ -477,6 +483,7 @@ flowchart TD
pkg_tui_demo --> pkg_agent_loop
pkg_tui_demo --> pkg_agent_spine_demo
pkg_tui_demo --> pkg_app_boot
+ pkg_tui_demo --> pkg_commands
pkg_tui_demo --> pkg_llm
pkg_tui_demo --> pkg_session
pkg_tui_demo --> pkg_session_persistence_jsonl
@@ -541,6 +548,7 @@ flowchart TD
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
+| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`scope`](../packages/core/scope) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) |
@@ -565,7 +573,7 @@ flowchart TD
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
+| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
@@ -577,11 +585,11 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
-| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
+| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
-| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
+| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl
index 893629bef6..61602dc289 100644
--- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_create","title":"Create goal","kind":"other","status":"in_progress","rawInput":"Finish the ACP goal-session snapshot proof"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_create","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_get","title":"Read current goal","kind":"read","status":"in_progress"}}}
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
index ce3fe8acef..6410d07df3 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}}
diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
index d9d2632bd0..739deae606 100644
--- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
index 4111ecc8de..d6964687d1 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
index 11178b9bc7..4d770492c5 100644
--- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
index 4146e8804d..c17327002f 100644
--- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
@@ -1,4 +1,5 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
index f3bd0b345c..003205b364 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
index 3d25d176ac..043607a926 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
index 47dc73536f..eb9762f95a 100644
--- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}}
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
index e5fd8608d6..5ff9ec5bdd 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}}
diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
index f941121f12..fa24d8140d 100644
--- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
@@ -1,4 +1,5 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n[Model attempt failed; any partial output above is discarded: simulated provider error (HTTP 401)]\n\n"}}}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}}
diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
index 92743b8133..de4c76482c 100644
--- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
index c8a9b320f0..e99bb3a5bb 100644
--- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
index fab47cc857..40223acda3 100644
--- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl
index b8b2f245ee..c42d0d5d56 100644
--- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
index d92ed5520b..635a185739 100644
--- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
index 44ce1184e9..c2dcf2a731 100644
--- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
index 712e8e5c3b..4ce2099ace 100644
--- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
index d06162a005..3244ee21c3 100644
--- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
index 270d1ace7c..f8c7edd015 100644
--- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
index 1cac540a29..e2e3bef55d 100644
--- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
index fb4f7cbbc5..dab4affa7e 100644
--- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
@@ -1,2 +1,3 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
index af42092168..653a7340f8 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
index bed3d3a03a..2aa177932e 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
index 48b5df1ac2..262ce3e2ea 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
index 74f4b9ea10..094e78be67 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
index 29023a8d45..a6accd8f18 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
@@ -1,3 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
index 15bf48cb81..44a5da1302 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
index 2d92b5b3e7..1c39ec6502 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
index 7870b73dc2..05fc598709 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
index cece2795f7..bc4266cc04 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
index 1a0e83d191..73e123a566 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
index 29023a8d45..a6accd8f18 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
@@ -1,3 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
index 15a91d1af3..2c602e4cc1 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
index a3e72075ed..0d727080d6 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
index 291525f825..e50144c03e 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
index 9a55a86f02..a9da0b4a4f 100644
--- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
index 8680db3d30..946a91f75a 100644
--- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
index 358e81f076..53197fe328 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
index 8469933a94..f1c2fd0634 100644
--- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
index a1b4b8c0cb..3e17783db5 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl
index 7f629a2d71..496f7568b8 100644
--- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
index e2941dd851..dd21b9674f 100644
--- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
index e5cc8bfa90..7bfd214919 100644
--- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
index bd4fb81d4a..4916b87bc8 100644
--- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
index 2b77e856e6..22e034b251 100644
--- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
index c717c3182a..6171c640e4 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
index 8771e50182..ee40cae2fd 100644
--- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
index 2c19d8feb9..9183664a0f 100644
--- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
index 03f482bcc6..24333f95a0 100644
--- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
index a55c6d6e01..3b233174fc 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
index 4e8db74ba6..38c5dcdfc1 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
@@ -1,5 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts
index 534207c875..2c62ba25e6 100644
--- a/examples/tui-agent/tests/tui.snapshot.ts
+++ b/examples/tui-agent/tests/tui.snapshot.ts
@@ -9,6 +9,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
+import CommandService from '@deepseek-ai/dsh-commands'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -179,6 +180,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false })
await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
await ctx.plugin(ToolWorkflow)
+ await ctx.plugin(CommandService)
if (scenario.composition === 'code' || scenario.composition === 'advanced') {
await ctx.plugin(WorkerCodeRuntime, {})
}
diff --git a/knip.json b/knip.json
index 51c3f99623..9cc4a658be 100644
--- a/knip.json
+++ b/knip.json
@@ -135,6 +135,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
+ "packages/ui/commands": {
+ "entry": ["tests/**/*.spec.ts"],
+ "project": ["src/**/*.ts", "tests/**/*.ts"]
+ },
"packages/examples/tui-demo": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 24e2ee7b2b..1174bfb9ec 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -198,6 +198,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'commands',
+ summary: 'Human-command registry.',
+ methods: [
+ {
+ signature: 'register(definition: CommandDefinition): () => void',
+ jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
+ },
+ {
+ signature: 'list(agent: Agent): readonly CommandDescriptor[]',
+ jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */',
+ },
+ {
+ signature: 'find(agent: Agent, name: string): CommandDefinition | undefined',
+ jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
+ },
+ {
+ signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise',
+ jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
+ },
+ ],
+ },
{
key: 'compact',
summary: 'Abstract compaction service.',
@@ -797,6 +819,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */',
summary: 'Ask composed answerers for one decision.',
},
+ {
+ name: 'commands/change',
+ mode: 'emit',
+ signature: '\'commands/change\'(): void',
+ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
+ summary: 'A command was registered or unregistered.',
+ },
{
name: 'fs/edit-intent',
mode: 'waterfall',
@@ -1119,6 +1148,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CollectedOutput',
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
},
+ {
+ name: 'CommandDefinition',
+ declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}',
+ },
+ {
+ name: 'CommandDescriptor',
+ declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
+ },
+ {
+ name: 'CommandInputDescriptor',
+ declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}',
+ },
+ {
+ name: 'CommandInvocation',
+ declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
+ },
+ {
+ name: 'CommandResult',
+ declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
+ },
{
name: 'CompactAgentContext',
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',
diff --git a/packages/examples/README.md b/packages/examples/README.md
index f13d15a8ea..46b5e7d923 100644
--- a/packages/examples/README.md
+++ b/packages/examples/README.md
@@ -5,9 +5,9 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
-| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
+| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + command registry + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
-| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
+| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md
index 92d75dec99..3526eef597 100644
--- a/packages/examples/acp-demo/README.md
+++ b/packages/examples/acp-demo/README.md
@@ -11,6 +11,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| Plugin | Why |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
+| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json
index 28d057c0a0..f74617f306 100644
--- a/packages/examples/acp-demo/package.json
+++ b/packages/examples/acp-demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-acp-demo",
- "description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio",
+ "description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -34,6 +34,7 @@
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
+ "@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -47,6 +48,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
+ "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts
index 98ba012829..6e7e389a92 100644
--- a/packages/examples/acp-demo/src/index.ts
+++ b/packages/examples/acp-demo/src/index.ts
@@ -1,7 +1,7 @@
/**
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
- * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It
- * writes nothing to stdout.
+ * human-command registry, JSONL session persistence, and the
+ * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout.
* It pre-creates no agents and leaves adapters, executors, and optional tools to
* the leaf, which must likewise avoid stdout loggers. Named exports are
* required so Loader retains this plugin's `Config` schema (see
@@ -12,6 +12,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
+import CommandService from '@deepseek-ai/dsh-commands'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -96,6 +97,7 @@ export const Config: z = z.object({
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
+ ctx.plugin(CommandService)
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, {
diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json
index b0e537574a..78090f6273 100644
--- a/packages/examples/acp-demo/tsconfig.json
+++ b/packages/examples/acp-demo/tsconfig.json
@@ -23,6 +23,9 @@
{
"path": "../../ui/acp"
},
+ {
+ "path": "../../ui/commands"
+ },
{
"path": "../../core/agent"
},
diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md
index b544e989fc..d944f9caed 100644
--- a/packages/examples/tui-demo/README.md
+++ b/packages/examples/tui-demo/README.md
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tui-demo
-The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
+The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), the human-command registry, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
@@ -9,6 +9,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
| Plugin | Why it is here |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
+| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
@@ -70,7 +71,7 @@ Fresh runs mint a `main-session-` session id and pass it to both the TUI a
#### What the model sees
-Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
+Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
#### Token effect
diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json
index 3bdc880023..7448858055 100644
--- a/packages/examples/tui-demo/package.json
+++ b/packages/examples/tui-demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
- "description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent",
+ "description": "Full-screen terminal app: agent spine + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
+ "@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
@@ -53,6 +54,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
+ "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts
index c4a13948e8..b4510ab5d6 100644
--- a/packages/examples/tui-demo/src/index.ts
+++ b/packages/examples/tui-demo/src/index.ts
@@ -1,7 +1,7 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
- * plus JSONL persistence, keyboard-backed user interaction, and one pre-created
- * agent whose exact session identity the TUI drives. Swappable adapters,
+ * plus human commands, JSONL persistence, keyboard-backed user interaction,
+ * and one pre-created agent whose exact session identity the TUI drives. Swappable adapters,
* executors, optional tools, and HMR stay in the leaf. This Loader plugin
* intentionally exposes named exports only; a default export would hide its
* `Config` schema (see docs/postmortem/0001).
@@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
+import CommandService from '@deepseek-ai/dsh-commands'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl, {
@@ -97,6 +98,7 @@ export const Config: z = z.object({
export function composeTuiApp(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
+ ctx.plugin(CommandService)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts
index 673f3e4f05..007120b4d5 100644
--- a/packages/examples/tui-demo/tests/tui-agent.spec.ts
+++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts
@@ -41,17 +41,19 @@ describe('dsh-tui-demo app', () => {
})
expect(calls.map(call => call.name)).toEqual([
+ 'CommandService',
'SessionPersistenceJsonl',
'UserInteractionService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
])
- expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
- const tuiConfig = calls[2]?.config as { sessionId: string }
+ expect(calls[0]?.config).toBeUndefined()
+ expect(calls[1]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
+ const tuiConfig = calls[3]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
- const spineConfig = calls[3]?.config as {
+ const spineConfig = calls[4]?.config as {
readonly agents: Array>
readonly maxParallelToolCalls: number
readonly persona: string
@@ -82,9 +84,9 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
- expect(calls[0]?.config).toEqual({ root: './.sessions' })
- expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
- expect((calls[3]?.config as { agents: Array> }).agents[0]).toMatchObject({
+ expect(calls[1]?.config).toEqual({ root: './.sessions' })
+ expect(calls[3]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
+ expect((calls[4]?.config as { agents: Array> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
@@ -99,9 +101,9 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
- const tuiConfig = calls[2]?.config as { sessionId: string }
+ const tuiConfig = calls[3]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
- expect((calls[3]?.config as { agents: Array> }).agents[0])
+ expect((calls[4]?.config as { agents: Array> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
})
diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json
index 2e92bb3b2b..cb3728904a 100644
--- a/packages/examples/tui-demo/tsconfig.json
+++ b/packages/examples/tui-demo/tsconfig.json
@@ -26,6 +26,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../ui/commands"
+ },
{
"path": "../agent-spine-demo"
},
diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md
index 45ad545458..89ca897d31 100644
--- a/packages/sdk/helper/README.md
+++ b/packages/sdk/helper/README.md
@@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities,
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
-Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator.
+Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge.
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts
index 8e7793ab8f..835a9a8710 100644
--- a/packages/sdk/helper/src/features/builtin/app.ts
+++ b/packages/sdk/helper/src/features/builtin/app.ts
@@ -73,6 +73,10 @@ class AppOption extends FeatureOption {
case 'acp':
return new ProjectContribution([
...appProjectResources(profile, this.id),
+ ...npmCordisConfigEntry(ID, {
+ id: 'commands',
+ name: '@deepseek-ai/dsh-commands',
+ }),
...npmCordisConfigEntry(ID, {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts
index 18540d4e67..4df8c5d3f2 100644
--- a/packages/sdk/helper/tests/project.spec.ts
+++ b/packages/sdk/helper/tests/project.spec.ts
@@ -283,6 +283,7 @@ describe('SdkProject and ProjectEditSession', () => {
edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
const acp = (await edit.commit()).project
expect(acp.profile.runInterface).toBe('acp')
+ expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' })
expect(acp.packageManifest().scripts).toMatchObject({
dev: 'dsh-sdk dev index.ts',
start: 'dsh-sdk start index.js',
diff --git a/packages/ui/README.md b/packages/ui/README.md
index bd6b6c9406..aad4a9b628 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are **
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
+| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
@@ -13,7 +14,7 @@ Integrations that expose the agent to an external editor or client. These are **
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
-A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel.
+A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md
index 7a1a9fb00e..32b523cccd 100644
--- a/packages/ui/acp/README.md
+++ b/packages/ui/acp/README.md
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
-The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
+The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| ACP method | Harness seam | Notes |
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
-| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
-| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
-| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
-| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
+| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
+| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands |
+| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
+| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
@@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
+## Human commands
+
+After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
+
+ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
+
## Session config options
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
@@ -108,6 +114,20 @@ Prompt tokens are data-dependent and remain in that session's history until comp
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
+### Human commands
+
+#### What the model sees
+
+Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests.
+
+#### Token effect
+
+Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost.
+
+#### KV Cache effect
+
+Command discovery, dispatch, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
+
### Human answers and permission decisions
#### What the model sees
@@ -170,3 +190,4 @@ Loading does not rewrite the stored log, but the next request is reconstructed u
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
+- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor.
diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md
index 497973731e..60c7b19adf 100644
--- a/packages/ui/acp/acp-feature-support.md
+++ b/packages/ui/acp/acp-feature-support.md
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
-The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
+The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -23,8 +23,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
-| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
-| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
+| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
+| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
@@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
-| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
+| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
@@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
-3. **Slash commands** (`available_commands_update`).
-4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
-5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
-6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
-7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
+3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
+4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
+5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
+6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
## Out of scope
diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json
index 94cdd10743..9c4fefaa01 100644
--- a/packages/ui/acp/package.json
+++ b/packages/ui/acp/package.json
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
+ "@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts
index 38eeb3f914..62455223a4 100644
--- a/packages/ui/acp/src/index.ts
+++ b/packages/ui/acp/src/index.ts
@@ -17,7 +17,9 @@ import {
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
+ type AnyMessage,
type AuthenticateRequest,
+ type AvailableCommand,
type CancelNotification,
type ContentBlock as AcpContentBlock,
type CreateElicitationRequest,
@@ -46,6 +48,7 @@ import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
@@ -77,13 +80,50 @@ import {
export const name = 'acp'
// Interface services back loading, presentation, interaction, and prompt assembly.
-export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
+export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
+/** Render arbitrary thrown values without trusting their string coercion. */
+function renderThrown(value: unknown): string {
+ try {
+ return String(value)
+ } catch {
+ return ''
+ }
+}
+
+/** Return a server-created session id carried by an outbound success response. */
+function responseSessionId(message: AnyMessage): SessionId | undefined {
+ if (!('result' in message) || typeof message.result !== 'object' || message.result === null
+ || !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') {
+ return undefined
+ }
+ return SessionId(message.result.sessionId)
+}
+
+/** Observe messages only after the wrapped ACP transport has written them. */
+function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream {
+ const writer = stream.writable.getWriter()
+ return {
+ readable: stream.readable,
+ writable: new WritableStream({
+ async write(message) {
+ await writer.write(message)
+ onWritten(message)
+ },
+ /* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream;
+ preserve the wrapped Stream contract for other consumers nonetheless */
+ close: () => writer.close(),
+ abort: (reason: unknown) => writer.abort(reason),
+ /* v8 ignore stop */
+ }),
+ }
+}
+
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
@@ -260,6 +300,8 @@ interface SessionRecord {
reject: (error: Error) => void
turn: number | undefined
} | undefined
+ /** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */
+ commandAbort: AbortController | undefined
/** Last idle switch per knob, anchored before the next prompt assembles. */
pendingSwitches: { preset?: string }
}
@@ -274,6 +316,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture
// injected services during apply(); lazy service reads in a handler fail.
const agents = ctx.agents
+ const commands = ctx.commands
const llm = ctx.llm
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -380,6 +423,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessions = new Map()
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set()
+ // A new-session response introduces its server-generated id to the client;
+ // keep its initial command snapshot pending until that response is written.
+ const pendingCommandSnapshots = new Map()
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Each new or loaded session snapshots the latest connection capability.
@@ -468,6 +514,43 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
+ /** Project the effective registry view onto ACP discovery metadata. */
+ const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
+ name: command.name,
+ description: command.description,
+ ...command.input === undefined ? {} : { input: { hint: command.input.hint } },
+ }))
+
+ /** Push the protocol's full-snapshot command catalog for one live session. */
+ const notifyCommands = (rec: SessionRecord): void => {
+ notify({
+ sessionId: rec.agent.session.id,
+ update: {
+ sessionUpdate: 'available_commands_update',
+ availableCommands: availableCommands(rec.agent),
+ },
+ })
+ }
+
+ /** Enqueue a new session's first command snapshot behind its written RPC response. */
+ const announceInitialCommands = (message: AnyMessage): void => {
+ const sessionId = responseSessionId(message)
+ if (sessionId === undefined) return
+ const rec = pendingCommandSnapshots.get(sessionId)
+ if (rec === undefined) return
+ pendingCommandSnapshots.delete(sessionId)
+ notifyCommands(rec)
+ }
+
+ // Registration and HMR removal can affect global or one scoped view; refresh
+ // every announced bridge-owned session and let the registry resolve each
+ // exact agent. A pending new-session snapshot will read the latest registry.
+ ctx.on('commands/change', () => {
+ for (const rec of sessions.values()) {
+ if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec)
+ }
+ })
+
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
const settlePrompt = (rec: SessionRecord, reason: StopReason): void => {
const inflight = rec.inflight
@@ -674,15 +757,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw internalError('connection closed during session/new')
}
- sessions.set(sessionId, {
+ const record: SessionRecord = {
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
target,
inflight: undefined,
+ commandAbort: undefined,
pendingSwitches: {},
- })
+ }
+ sessions.set(sessionId, record)
+ pendingCommandSnapshots.set(sessionId, record)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
},
@@ -763,6 +849,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled,
target,
inflight: undefined,
+ commandAbort: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -787,6 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
+ notifyCommands(record)
const configOptions = configOptionsFor(agent, directory)
return configOptions.length > 0 ? { configOptions } : {}
} finally {
@@ -797,7 +885,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
async prompt(params: PromptRequest): Promise {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
- if (rec.inflight !== undefined) {
+ if (rec.inflight !== undefined || rec.commandAbort !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
@@ -810,6 +898,52 @@ export function apply(ctx: Context, config: AcpConfig): void {
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
+ // ACP command prompts may carry additional supported content blocks.
+ // The same lossless flattening used for model prompts supplies their
+ // unstructured command input; unsupported kinds were rejected above.
+ const commandLine = text.startsWith('/') ? text : undefined
+ if (commandLine !== undefined) {
+ const controller = new AbortController()
+ rec.commandAbort = controller
+ try {
+ const result = await commands.execute(rec.agent, commandLine, controller.signal)
+ if (result !== undefined && result.text !== undefined && result.text !== '') {
+ notify({
+ sessionId: rec.agent.session.id,
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: result.kind === 'error' ? `Error: ${result.text}` : result.text,
+ },
+ },
+ })
+ } else if (result === undefined) {
+ notify({
+ sessionId: rec.agent.session.id,
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: `Error: unknown command: ${commandLine}` },
+ },
+ })
+ }
+ return { stopReason: 'end_turn' }
+ } catch (error: unknown) {
+ if (controller.signal.aborted) return { stopReason: 'cancelled' }
+ const rendered = renderThrown(error)
+ logger.warn(`acp: command failed: ${rendered}`)
+ notify({
+ sessionId: rec.agent.session.id,
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: `Error: command failed: ${rendered}` },
+ },
+ })
+ return { stopReason: 'end_turn' }
+ } finally {
+ rec.commandAbort = undefined
+ }
+ }
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
@@ -837,8 +971,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
- rec.agent.cancel('session/cancel')
- settlePrompt(rec, 'cancelled')
+ if (rec.commandAbort !== undefined) {
+ rec.commandAbort.abort(new Error('session/cancel'))
+ } else {
+ rec.agent.cancel('session/cancel')
+ settlePrompt(rec, 'cancelled')
+ }
return Promise.resolve()
},
@@ -908,7 +1046,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
Writable.toWeb(process.stdout) as WritableStream,
Readable.toWeb(process.stdin) as ReadableStream,
)
- conn = new AgentSideConnection(makeAgent, stream)
+ conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands))
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
@@ -946,12 +1084,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
// installed yet) must observe this after its await and refuse to install a
// post-teardown record. Set even when there are no live sessions.
closed = true
+ pendingCommandSnapshots.clear()
const recs = [...sessions.values()]
sessions.clear()
if (recs.length === 0) return Promise.resolve()
quiescing = (async () => {
await Promise.all(recs.map(async (rec) => {
settlePrompt(rec, 'cancelled')
+ rec.commandAbort?.abort(new Error('ACP connection closed'))
// Per-agent dispose (the AgentHandle disposer): unregister this agent,
// stop its loop (sets disposed + aborts the in-flight step), await
// quiescence (the loop exit + final flush), and remove its session — so
diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts
new file mode 100644
index 0000000000..71aae1ea64
--- /dev/null
+++ b/packages/ui/acp/tests/commands.spec.ts
@@ -0,0 +1,276 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
+
+function commandUpdates(harness: BridgeHarness, sessionId: string) {
+ return harness.sessionUpdates.filter(update => update.sessionId === sessionId
+ && update.update.sessionUpdate === 'available_commands_update')
+}
+
+function messageText(harness: BridgeHarness, sessionId: string): string {
+ return harness.sessionUpdates
+ .filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk')
+ .map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
+ ? update.content.text : '')
+ .join('')
+}
+
+describe('ACP plugin commands', () => {
+ let storageDir: string
+ let harness: BridgeHarness | undefined
+
+ beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) })
+ afterEach(async () => {
+ if (harness !== undefined) await harness.dispose()
+ harness = undefined
+ await rm(storageDir, { recursive: true, force: true })
+ })
+
+ it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ harness.ctx.commands.register({
+ name: 'inspect',
+ description: 'Inspect the session',
+ input: { hint: '' },
+ handler: () => ({ kind: 'success' }),
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
+ sessionUpdate: 'available_commands_update',
+ availableCommands: [{
+ name: 'inspect',
+ description: 'Inspect the session',
+ input: { hint: '' },
+ }],
+ })
+ })
+
+ const dispose = harness.ctx.commands.register({
+ name: 'alpha',
+ description: 'Alpha command',
+ handler: () => ({ kind: 'success' }),
+ })
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
+ availableCommands: [{ name: 'alpha' }, { name: 'inspect' }],
+ })
+ })
+ dispose()
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
+ availableCommands: [{ name: 'inspect' }],
+ })
+ })
+ })
+
+ it('re-advertises commands after loading a persisted session', async () => {
+ const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] })
+ await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] })
+ await live.dispose()
+
+ harness = await makeBridgeHarness({ storageDir })
+ harness.ctx.commands.register({
+ name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }),
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
+
+ expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({
+ availableCommands: [{ name: 'loaded', description: 'Loaded command' }],
+ })
+ })
+
+ it('coalesces registry changes before a new session command snapshot is announced', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ harness.ctx.commands.register({
+ name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
+ })
+
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
+ expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
+ availableCommands: [{ name: 'raced' }],
+ })
+ })
+ })
+
+ it('executes a known single-text command directly and never sends it to the model', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
+ harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen })
+ harness.ctx.commands.register({
+ name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }),
+ })
+ harness.ctx.commands.register({
+ name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }),
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ const response = await harness.client.prompt({
+ sessionId,
+ prompt: [{ type: 'text', text: '/direct raw args ' }],
+ })
+
+ expect(response.stopReason).toBe('end_turn')
+ expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
+ expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
+ const updatesAfterText = harness.sessionUpdates.length
+ await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
+ await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] })
+ expect(harness.sessionUpdates).toHaveLength(updatesAfterText)
+ expect(harness.adapter.requests).toHaveLength(0)
+ expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
+ })
+
+ it('renders expected command errors and rejects unknown slash commands without model fallback', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ harness.ctx.commands.register({
+ name: 'denied',
+ description: 'Deny directly',
+ handler: () => ({ kind: 'error', text: 'not allowed now' }),
+ })
+ harness.ctx.commands.register({
+ name: 'throws',
+ description: 'Throw an ordinary error',
+ handler: () => { throw new Error('handler exploded') },
+ })
+ harness.ctx.commands.register({
+ name: 'hostile',
+ description: 'Throw a hostile value',
+ handler: () => {
+ throw { toString(): string { throw new Error('coercion exploded') } }
+ },
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] }))
+ .resolves.toEqual({ stopReason: 'end_turn' })
+ await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] }))
+ .resolves.toEqual({ stopReason: 'end_turn' })
+ await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] }))
+ .resolves.toEqual({ stopReason: 'end_turn' })
+ await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] }))
+ .resolves.toEqual({ stopReason: 'end_turn' })
+
+ expect(messageText(harness, sessionId)).toContain('Error: not allowed now')
+ expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input')
+ expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded')
+ expect(messageText(harness, sessionId)).toContain('Error: command failed: ')
+ expect(harness.adapter.requests).toHaveLength(0)
+ })
+
+ it('flattens supported command prompt blocks without invoking the model', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' }))
+ harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ await expect(harness.client.prompt({
+ sessionId,
+ prompt: [
+ { type: 'text', text: '/direct' },
+ { type: 'text', text: ' extra' },
+ { type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' },
+ ],
+ })).resolves.toEqual({ stopReason: 'end_turn' })
+ expect(command).toHaveBeenCalledWith(expect.objectContaining({
+ rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n',
+ }))
+ expect(messageText(harness, sessionId)).toContain('combined')
+ expect(harness.adapter.requests).toHaveLength(0)
+ })
+
+ it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ let started!: () => void
+ const ready = new Promise((resolve) => { started = resolve })
+ harness.ctx.commands.register({
+ name: 'wait',
+ description: 'Wait for cancellation',
+ handler: ({ signal }) => {
+ started()
+ return new Promise((resolve) => {
+ signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true })
+ })
+ },
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })
+ await ready
+ await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }))
+ .rejects.toThrow(/already in flight/)
+ await harness.client.cancel({ sessionId: a.sessionId })
+
+ await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
+ await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] }))
+ .resolves.toEqual({ stopReason: 'end_turn' })
+ expect(messageText(harness, a.sessionId)).not.toContain('late abort result')
+ })
+
+ it('aborts an in-flight command when the ACP bridge is disposed', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ let started!: () => void
+ const ready = new Promise((resolve) => { started = resolve })
+ let commandSignal: AbortSignal | undefined
+ harness.ctx.commands.register({
+ name: 'wait-dispose',
+ description: 'Wait for bridge disposal',
+ handler: ({ signal }) => {
+ commandSignal = signal
+ started()
+ return new Promise(() => {})
+ },
+ })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] })
+ await ready
+ await harness.acpFiber.dispose()
+
+ expect(commandSignal?.aborted).toBe(true)
+ await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
+ })
+
+ it('resolves scoped command catalogs and execution independently per session', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ const agentA = harness.ctx.agents.get(SessionId(a.sessionId))
+ if (agentA === undefined) throw new Error('session A has no agent')
+ await agentA.ctx.inject(['commands'], (commandCtx) => {
+ commandCtx.commands.register({
+ name: 'private', description: 'Only session A',
+ handler: () => ({ kind: 'success', text: 'A ONLY' }),
+ })
+ })
+
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] })
+ })
+ expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] })
+ await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] })
+ await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] })
+ expect(messageText(harness, a.sessionId)).toContain('A ONLY')
+ expect(messageText(harness, b.sessionId)).toContain('unknown command')
+ })
+})
diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts
index 35dd54a634..fdbaf241e6 100644
--- a/packages/ui/acp/tests/edges.spec.ts
+++ b/packages/ui/acp/tests/edges.spec.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await vi.waitFor(() => {
+ expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
+ })
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts
index b8b47b80d5..b728ee5bfc 100644
--- a/packages/ui/acp/tests/harness.ts
+++ b/packages/ui/acp/tests/harness.ts
@@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo,
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import CommandService from '@deepseek-ai/dsh-commands'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
@@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: {
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: options.persona ?? '' },
})
+ await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(UserInteractionService)
diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json
index de4ccd3650..91e1272212 100644
--- a/packages/ui/acp/tsconfig.json
+++ b/packages/ui/acp/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../../core/tools"
},
+ {
+ "path": "../commands"
+ },
{
"path": "../user-interaction"
},
diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md
new file mode 100644
index 0000000000..6e0efa458b
--- /dev/null
+++ b/packages/ui/commands/README.md
@@ -0,0 +1,39 @@
+# @deepseek-ai/dsh-commands
+
+Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping.
+
+## Service contract
+
+`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
+
+`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
+
+`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
+
+Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
+
+## Composition
+
+The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly.
+
+## Model Experience
+
+### Direct human commands
+
+#### What the model sees
+
+Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt.
+
+#### Token effect
+
+Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
+
+#### KV Cache effect
+
+Registry metadata, command input, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
+
+## Known Limitations and Deferred Work
+
+- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns.
+- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
+- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json
new file mode 100644
index 0000000000..ef7e54e1c1
--- /dev/null
+++ b/packages/ui/commands/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@deepseek-ai/dsh-commands",
+ "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-agent": "^0.0.1",
+ "@deepseek-ai/dsh-scope": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-scope": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts
new file mode 100644
index 0000000000..16e665e71f
--- /dev/null
+++ b/packages/ui/commands/src/index.ts
@@ -0,0 +1,321 @@
+/**
+ * Plugin-owned human-command registry shared by interactive UI adapters.
+ * @module @deepseek-ai/dsh-commands
+ */
+
+import { Context, Service } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { scopeOf } from '@deepseek-ai/dsh-scope'
+import type { ScopeKey } from '@deepseek-ai/dsh-scope'
+
+export const name = 'commands'
+
+const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
+
+/** Immutable command input metadata compatible with ACP unstructured input. */
+export interface CommandInputDescriptor {
+ /** Placeholder shown before the user supplies free-form input. */
+ readonly hint: string
+}
+
+/** Invocation passed to one registered command handler. */
+export interface CommandInvocation {
+ /** Exact agent whose human-facing surface received the command. */
+ readonly agent: Agent
+ /** Exact text following the registered command name, including separator whitespace. */
+ readonly rawInput: string
+ /** Cancellation signal owned by the dispatching UI request. */
+ readonly signal: AbortSignal
+}
+
+/** Expected command outcome rendered directly by the dispatching UI. */
+export type CommandResult =
+ | { readonly kind: 'success'; readonly text?: string }
+ | { readonly kind: 'error'; readonly text: string }
+
+/** Plugin-owned command registration. */
+export interface CommandDefinition {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+ /** Execute against the receiving agent without sending the command to the model. */
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise
+}
+
+/** Handler-free immutable command view returned to UI adapters. */
+export interface CommandDescriptor {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+}
+
+/** Syntactically valid slash command before registry resolution. */
+export interface ParsedCommand {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Exact text following the command name. */
+ readonly rawInput: string
+}
+
+interface RegisteredCommand {
+ readonly definition: CommandDefinition
+ readonly descriptor: CommandDescriptor
+}
+
+declare module 'cordis' {
+ interface Context {
+ commands: CommandService
+ }
+
+ interface Events {
+ /**
+ * A command was registered or unregistered. This is an unfiltered registry
+ * notification because a global or scoped change may affect any UI view.
+ * Observer failures are contained and cannot veto the registry mutation.
+ * @mode emit
+ */
+ 'commands/change'(): void
+ }
+}
+
+/**
+ * Parse an exact slash command without normalizing its trailing input.
+ *
+ * @param line - Complete candidate command line.
+ * @returns The parsed command, or `undefined` when the line is not a command.
+ */
+export function parseCommand(line: string): ParsedCommand | undefined {
+ const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
+ if (match === null) return undefined
+ const name = match[1]
+ /* v8 ignore next -- the first capture is required whenever the regular expression matches */
+ if (name === undefined) return undefined
+ return Object.freeze({ name, rawInput: line.slice(match[0].length) })
+}
+
+/** Convert arbitrary abort reasons to one stable rejected Error. */
+function abortError(signal: AbortSignal): Error {
+ if (signal.reason instanceof Error) return signal.reason
+ return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
+}
+
+/** Render arbitrary thrown values without trusting their string coercion. */
+function renderThrown(value: unknown): string {
+ try {
+ return String(value)
+ } catch {
+ return ''
+ }
+}
+
+/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
+function withAbort(promise: Promise, signal: AbortSignal): Promise {
+ if (signal.aborted) return Promise.reject(abortError(signal))
+ return new Promise((resolve, reject) => {
+ const onAbort = (): void => {
+ signal.removeEventListener('abort', onAbort)
+ reject(abortError(signal))
+ }
+ signal.addEventListener('abort', onAbort, { once: true })
+ promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort)
+ resolve(value)
+ },
+ (error: unknown) => {
+ signal.removeEventListener('abort', onAbort)
+ reject(error instanceof Error
+ ? error
+ : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
+ },
+ )
+ })
+}
+
+/** Reject invalid command metadata before it can reach a UI protocol. */
+function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
+ if (!COMMAND_NAME.test(definition.name)) {
+ throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
+ }
+ if (typeof definition.description !== 'string') {
+ throw new TypeError(`command "${definition.name}" description must be a string`)
+ }
+ if (definition.description.trim().length === 0) {
+ throw new TypeError(`command "${definition.name}" description must not be empty`)
+ }
+ if (typeof definition.handler !== 'function') {
+ throw new TypeError(`command "${definition.name}" handler must be a function`)
+ }
+ const rawInput: unknown = definition.input
+ let input: CommandInputDescriptor | undefined
+ if (rawInput !== undefined) {
+ if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
+ || typeof rawInput.hint !== 'string') {
+ throw new TypeError(`command "${definition.name}" input hint must be a string`)
+ }
+ if (rawInput.hint.trim().length === 0) {
+ throw new TypeError(`command "${definition.name}" input hint must not be empty`)
+ }
+ input = Object.freeze({ hint: rawInput.hint })
+ }
+ const normalized = Object.freeze({
+ name: definition.name,
+ description: definition.description,
+ ...input === undefined ? {} : { input },
+ handler: definition.handler,
+ })
+ const descriptor = Object.freeze({
+ name: normalized.name,
+ description: normalized.description,
+ ...normalized.input === undefined ? {} : { input: normalized.input },
+ })
+ return { definition: normalized, descriptor }
+}
+
+/** Validate and detach an untrusted handler result at the registry boundary. */
+function normalizeResult(command: string, value: unknown): CommandResult {
+ if (typeof value !== 'object' || value === null || !('kind' in value)) {
+ throw new TypeError(`command "${command}" handler must return a CommandResult`)
+ }
+ const result = value as { kind?: unknown; text?: unknown }
+ if (result.kind === 'success') {
+ if (result.text !== undefined && typeof result.text !== 'string') {
+ throw new TypeError(`command "${command}" success text must be a string when supplied`)
+ }
+ return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
+ }
+ if (result.kind === 'error') {
+ if (typeof result.text !== 'string' || result.text.trim().length === 0) {
+ throw new TypeError(`command "${command}" error text must be a non-empty string`)
+ }
+ return Object.freeze({ kind: 'error', text: result.text })
+ }
+ throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
+}
+
+/**
+ * Human-command registry. Plain-context definitions are global; definitions
+ * registered through a command-injected child of an agent context shadow
+ * globals for that agent.
+ */
+export class CommandService extends Service {
+ private readonly global = new Map()
+ private readonly scoped = new Map>()
+
+ constructor(ctx: Context) {
+ super(ctx, 'commands')
+ }
+
+ /**
+ * Register a global or calling-agent-scoped command.
+ * @param definition - discovery metadata and direct UI handler.
+ * @returns the exact effect disposer that unregisters this definition.
+ */
+ register(definition: CommandDefinition): () => void {
+ const scope = scopeOf(this.ctx)
+ const registered = normalizeDefinition(definition)
+ const dispose = this.ctx.effect(function* (this: CommandService) {
+ const layer = scope === undefined ? this.global : this.layerFor(scope)
+ if (layer.has(registered.definition.name)) {
+ throw new Error(scope === undefined
+ ? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
+ : `command "${registered.definition.name}" is already registered in this scope`)
+ }
+ layer.set(registered.definition.name, registered)
+ yield () => {
+ layer.delete(registered.definition.name)
+ if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
+ this.notifyChange()
+ }
+ this.notifyChange()
+ }.bind(this), 'commands.register()')
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
+ return dispose
+ }
+
+ /**
+ * List the effective immutable command descriptors for one agent.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @returns name-sorted descriptors after scoped shadowing.
+ */
+ list(agent: Agent): readonly CommandDescriptor[] {
+ return Object.freeze([...this.view(agent).values()]
+ .map(command => command.descriptor)
+ // Names are unique in the effective view, so equality is impossible.
+ .sort((left, right) => left.name < right.name ? -1 : 1))
+ }
+
+ /**
+ * Resolve one effective command definition.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @param name - command name without a slash.
+ * @returns the scoped shadow or global definition.
+ */
+ find(agent: Agent, name: string): CommandDefinition | undefined {
+ return this.view(agent).get(name)?.definition
+ }
+
+ /**
+ * Parse and execute a known command without sending it to the model.
+ * @param agent - exact receiving agent.
+ * @param line - complete slash-command line.
+ * @param signal - cancellation signal owned by the UI request.
+ * @returns a detached result, or `undefined` when syntax or name does not resolve.
+ */
+ async execute(
+ agent: Agent,
+ line: string,
+ signal: AbortSignal,
+ ): Promise {
+ const parsed = parseCommand(line)
+ if (parsed === undefined) return undefined
+ const command = this.view(agent).get(parsed.name)
+ if (command === undefined) return undefined
+ if (signal.aborted) throw abortError(signal)
+ const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
+ const output = command.definition.handler(invocation)
+ return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
+ }
+
+ /** Resolve global definitions followed by exact scoped shadows. */
+ private view(agent: Agent): Map {
+ const visible = new Map(this.global)
+ for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
+ return visible
+ }
+
+ /** Create the registration layer for one agent scope on demand. */
+ private layerFor(scope: ScopeKey): Map {
+ let layer = this.scoped.get(scope)
+ if (layer === undefined) {
+ layer = new Map()
+ this.scoped.set(scope, layer)
+ }
+ return layer
+ }
+
+ /** Notify every registry observer without making UI refresh load-bearing. */
+ private notifyChange(): void {
+ // Cordis emit uses Array.map: one synchronous throw starves later listeners,
+ // and returned promises are discarded. Registry notifications are
+ // non-vetoing, so contain each callback independently.
+ for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
+ try {
+ const returned: unknown = callback()
+ void Promise.resolve(returned).catch((error: unknown) => {
+ this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
+ })
+ } catch (error: unknown) {
+ this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
+ }
+ }
+ }
+}
+
+export default CommandService
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
new file mode 100644
index 0000000000..839ccb0297
--- /dev/null
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -0,0 +1,294 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import type { Scope } from '@deepseek-ai/dsh-scope'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
+
+function command(name: string, text = `ran:${name}`): CommandDefinition {
+ return {
+ name,
+ description: `command ${name}`,
+ handler: () => ({ kind: 'success', text }),
+ }
+}
+
+async function mount(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(CommandService)
+ return ctx
+}
+
+/** Mint a scope whose key is sufficient for registry lookup and invocation. */
+async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
+ const agent = { id: name as SessionId } as Agent
+ let scope!: Scope
+ await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
+ return { scope, agent }
+}
+
+describe('parseCommand()', () => {
+ it.each([
+ ['/goal', { name: 'goal', rawInput: '' }],
+ ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
+ ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
+ ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
+ ] as const)('parses %j without normalizing trailing input', (line, expected) => {
+ expect(parseCommand(line)).toEqual(expected)
+ })
+
+ it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
+ expect(parseCommand(line)).toBeUndefined()
+ })
+})
+
+describe('CommandService', () => {
+ it('lists immutable global descriptors with input metadata', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ const definition: CommandDefinition = {
+ name: 'inspect',
+ description: 'Inspect state',
+ input: { hint: '' },
+ handler: () => ({ kind: 'success' }),
+ }
+ ctx.commands.register(definition)
+
+ const listed = ctx.commands.list(agent)
+ expect(listed).toEqual([{
+ name: 'inspect',
+ description: 'Inspect state',
+ input: { hint: '' },
+ }])
+ expect(Object.isFrozen(listed)).toBe(true)
+ expect(Object.isFrozen(listed[0])).toBe(true)
+ expect(Object.isFrozen(listed[0]?.input)).toBe(true)
+ expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
+ expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
+ })
+
+ it('sorts distinct effective command names', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('zeta'))
+ ctx.commands.register(command('alpha'))
+ ctx.commands.register(command('middle'))
+ expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
+ })
+
+ it('uses agent-scoped shadows and removes them with their scope', async () => {
+ const ctx = await mount()
+ const { scope, agent } = await mintAgentScope(ctx, 'a')
+ const other = { id: 'other' as SessionId } as Agent
+ ctx.commands.register(command('shared', 'global'))
+ scope.ctx.commands.register(command('shared', 'scoped'))
+
+ expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
+ expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
+ expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
+ expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
+ .toEqual({ kind: 'success', text: 'scoped' })
+
+ await scope.dispose()
+ expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
+ })
+
+ it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
+ const ctx = await mount()
+ const { scope } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('same'))
+ expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
+ scope.ctx.commands.register(command('same'))
+ expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
+ })
+
+ it('notifies on registration and disposal while containing broken observers', async () => {
+ const ctx = await mount()
+ const changed = vi.fn()
+ ctx.on('commands/change', changed)
+ const dispose = ctx.commands.register(command('live'))
+ dispose()
+ dispose()
+ expect(changed).toHaveBeenCalledTimes(2)
+
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+ ctx.on('commands/change', () => { throw new Error('observer threw') })
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
+ ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
+ const afterFailures = vi.fn()
+ ctx.on('commands/change', afterFailures)
+ const removeContained = ctx.commands.register(command('contained'))
+ const { agent } = await mintAgentScope(ctx, 'a')
+ expect(ctx.commands.find(agent, 'contained')).toBeDefined()
+ expect(afterFailures).toHaveBeenCalledTimes(1)
+ await vi.waitFor(() => {
+ expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
+ expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
+ })
+ removeContained()
+ expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
+ expect(afterFailures).toHaveBeenCalledTimes(2)
+ })
+
+ it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
+ const ctx = await mount()
+ expect(() => ctx.commands.register({
+ ...command('description-type'),
+ description: undefined,
+ } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
+ expect(() => ctx.commands.register({
+ ...command('hint-type'),
+ input: { hint: 42 },
+ } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
+ expect(() => ctx.commands.register({
+ ...command('input-type'),
+ input: null,
+ } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
+ })
+
+ it('passes exact invocation context and detaches valid handler results', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
+ ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
+ const controller = new AbortController()
+
+ const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
+
+ expect(result).toEqual({ kind: 'success', text: 'ok' })
+ expect(Object.isFrozen(result)).toBe(true)
+ expect(seen).toHaveBeenCalledWith(expect.objectContaining({
+ agent,
+ rawInput: ' untouched ',
+ signal: controller.signal,
+ }))
+ await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
+ await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
+ })
+
+ it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ let release!: (result: { kind: 'success'; text: string }) => void
+ ctx.commands.register({
+ name: 'wait',
+ description: 'Wait',
+ handler: () => new Promise((resolve) => { release = resolve }),
+ })
+ const running = new AbortController()
+ const promise = ctx.commands.execute(agent, '/wait', running.signal)
+ running.abort('operator cancelled command')
+ await expect(promise).rejects.toThrow('operator cancelled command')
+ release({ kind: 'success', text: 'late' })
+
+ const already = new AbortController()
+ already.abort(new Error('already gone'))
+ await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
+
+ const defaultReason = new AbortController()
+ defaultReason.abort({ source: 'test' })
+ await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
+ })
+
+ it('propagates an asynchronously rejected handler', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({
+ name: 'reject',
+ description: 'Reject',
+ handler: () => Promise.reject(new Error('handler rejected')),
+ })
+ await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
+ .rejects.toThrow('handler rejected')
+
+ ctx.commands.register({
+ name: 'reject-value',
+ description: 'Reject a non-Error value',
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
+ handler: () => Promise.reject('not an Error'),
+ })
+ await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
+ .rejects.toThrow('command handler rejected with a non-Error value: not an Error')
+
+ const hostile = { toString(): string { throw new Error('cannot render') } }
+ ctx.commands.register({
+ name: 'reject-hostile',
+ description: 'Reject an unrenderable value',
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
+ handler: () => Promise.reject(hostile),
+ })
+ await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
+ .rejects.toMatchObject({
+ message: 'command handler rejected with a non-Error value: ',
+ cause: hostile,
+ })
+ })
+
+ it('observes an abort triggered synchronously inside the handler', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ const controller = new AbortController()
+ ctx.commands.register({
+ name: 'self-abort',
+ description: 'Abort before returning',
+ handler: () => {
+ controller.abort('aborted in handler')
+ return { kind: 'success' }
+ },
+ })
+ await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
+ .rejects.toThrow('aborted in handler')
+ })
+
+ it('returns a detached expected-error result', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({
+ name: 'denied',
+ description: 'Denied',
+ handler: () => ({ kind: 'error', text: 'not now' }),
+ })
+ const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
+ expect(result).toEqual({ kind: 'error', text: 'not now' })
+ expect(Object.isFrozen(result)).toBe(true)
+
+ ctx.commands.register({
+ name: 'silent',
+ description: 'No output',
+ handler: () => ({ kind: 'success' }),
+ })
+ const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
+ expect(silent).toEqual({ kind: 'success' })
+ expect(Object.isFrozen(silent)).toBe(true)
+ })
+
+ it.each([
+ [{ ...command('Bad') }, /command name/],
+ [{ ...command('empty-description'), description: ' ' }, /description/],
+ [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
+ [{ ...command('bad-handler'), handler: undefined }, /handler/],
+ ] as const)('rejects invalid definition %#', async (definition, expected) => {
+ const ctx = await mount()
+ expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
+ })
+
+ it.each([
+ [undefined, /CommandResult/],
+ [null, /CommandResult/],
+ [{}, /CommandResult/],
+ [{ kind: 'success', text: 1 }, /success text/],
+ [{ kind: 'error', text: '' }, /error text/],
+ [{ kind: 'error', text: 1 }, /error text/],
+ [{ kind: 'future', text: 'x' }, /unknown result kind/],
+ ] as const)('rejects malformed handler result %j', async (output, expected) => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({
+ name: 'broken',
+ description: 'Broken',
+ handler: () => output as never,
+ })
+ await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
+ })
+})
diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json
new file mode 100644
index 0000000000..478cc26479
--- /dev/null
+++ b/packages/ui/commands/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../core/agent"
+ },
+ {
+ "path": "../../core/scope"
+ }
+ ]
+}
diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md
index dd6553d09f..550652c22d 100644
--- a/packages/ui/tui/README.md
+++ b/packages/ui/tui/README.md
@@ -6,13 +6,13 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
-This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
+This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
-While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
+While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
## Config
@@ -39,7 +39,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
maxToolOutputLines: 12
```
-Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
+Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color
@@ -51,7 +51,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees
-Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
+Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
#### Token effect
diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json
index 5e7c8d060c..136acde7bc 100644
--- a/packages/ui/tui/package.json
+++ b/packages/ui/tui/package.json
@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
+ "@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -39,6 +40,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
+ "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts
index db696dde2a..cbb3593209 100644
--- a/packages/ui/tui/src/index.ts
+++ b/packages/ui/tui/src/index.ts
@@ -35,6 +35,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
+import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
@@ -55,7 +56,7 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
-export const inject = ['agents', 'userInteraction', 'tools']
+export const inject = ['agents', 'commands', 'userInteraction', 'tools']
/** Presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
@@ -855,6 +856,7 @@ export function createTuiChat(
const allToolCards = new Set()
const liveErrors = new Set()
const questionQueue: PendingQuestion[] = []
+ const commandControllers = new Set()
let activeQuestion: PendingQuestion | undefined
const welcome = config.welcome ?? 'ready.'
@@ -1129,6 +1131,8 @@ export function createTuiChat(
shuttingDown ??= (async () => {
disposed = true
clearStatus()
+ for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
+ commandControllers.clear()
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
@@ -1153,16 +1157,6 @@ export function createTuiChat(
void shutdown(true)
}
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
- { name: 'help', description: 'Show keyboard shortcuts and commands' },
- { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' },
- { name: 'cancel', description: 'Cancel the active turn' },
- { name: 'reasoning', description: 'Toggle reasoning blocks' },
- { name: 'tools', description: 'Expand or collapse all tool cards' },
- { name: 'redraw', description: 'Invalidate components and redraw the terminal' },
- { name: 'exit', description: 'Exit after the active turn reaches idle' },
- ], agent.session.header.cwd ?? process.cwd()))
-
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1182,52 +1176,107 @@ export function createTuiChat(
}
const showHelp = (): void => {
+ const commandLines = ctx.commands.list(agent).map((command) => {
+ const input = command.input === undefined ? '' : ` ${command.input.hint}`
+ return `/${command.name}${input} — ${command.description}`
+ })
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
chat.addChild(new Text([
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
- '/help /clear /cancel /reasoning /tools /redraw /exit',
+ '',
+ ...commandLines,
].map(line => palette.muted(line)).join('\n'), 1, 0))
requestRender()
}
+ const refreshCommandAutocomplete = (): void => {
+ editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
+ ctx.commands.list(agent).map(command => ({
+ name: command.name,
+ description: command.description,
+ })),
+ agent.session.header.cwd ?? process.cwd(),
+ ))
+ }
+ const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
+ refreshCommandAutocomplete()
+
+ // The agent scope is minted by agent-loop and intentionally inherits only
+ // that core plugin's dependencies. A child command producer declares its own
+ // UI-service dependency while retaining the parent agent scope and lifetime.
+ const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
+ commandCtx.commands.register({
+ name: 'help',
+ description: 'Show keyboard shortcuts and commands',
+ handler: () => { showHelp(); return { kind: 'success' } },
+ })
+ commandCtx.commands.register({
+ name: 'clear',
+ description: 'Clear the transcript view (session history is unchanged)',
+ handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
+ })
+ commandCtx.commands.register({
+ name: 'cancel',
+ description: 'Cancel the active turn',
+ handler: () => {
+ if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
+ agent.cancel('cancelled from terminal')
+ return { kind: 'success', text: 'Cancellation requested.' }
+ },
+ })
+ commandCtx.commands.register({
+ name: 'reasoning',
+ description: 'Toggle reasoning blocks',
+ handler: () => { toggleReasoning(); return { kind: 'success' } },
+ })
+ commandCtx.commands.register({
+ name: 'tools',
+ description: 'Expand or collapse all tool cards',
+ handler: () => { toggleTools(); return { kind: 'success' } },
+ })
+ commandCtx.commands.register({
+ name: 'redraw',
+ description: 'Invalidate components and redraw the terminal',
+ handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
+ })
+ commandCtx.commands.register({
+ name: 'exit',
+ description: 'Exit after the active turn reaches idle',
+ handler: () => { requestExit(); return { kind: 'success' } },
+ })
+ })
+
+ const runCommand = (text: string): void => {
+ const controller = new AbortController()
+ commandControllers.add(controller)
+ void ctx.commands.execute(agent, text, controller.signal).then(
+ (result) => {
+ if (disposed) return
+ if (result === undefined) {
+ appendNotice(`Unknown command: ${text}`, 'warning')
+ } else if (result.text !== undefined && result.text !== '') {
+ appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
+ }
+ },
+ (error: unknown) => {
+ if (!disposed) {
+ appendNotice(`Command failed: ${errorChain(error)}`, 'error')
+ }
+ },
+ ).finally(() => { commandControllers.delete(controller) })
+ }
+
editor.onSubmit = (value: string) => {
const text = value.trim()
if (text === '') return
editor.addToHistory(text)
editor.setText('')
- switch (text) {
- case '/help':
- showHelp()
- return
- case '/clear':
- chat.clear()
- requestRender()
- return
- case '/cancel':
- if (agent.status === 'running') agent.cancel('cancelled from terminal')
- else appendNotice('The agent is already idle.')
- return
- case '/reasoning':
- toggleReasoning()
- return
- case '/tools':
- toggleTools()
- return
- case '/redraw':
- ui.invalidate()
- ui.requestRender(true)
- return
- case '/exit':
- requestExit()
- return
- default:
- if (text.startsWith('/')) {
- appendNotice(`Unknown command: ${text}`, 'warning')
- return
- }
+ if (value.startsWith('/')) {
+ runCommand(value)
+ return
}
if (agent.status === 'disposed') {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
@@ -1304,6 +1353,7 @@ export function createTuiChat(
const detachListeners = (): void => {
removeInputListener()
+ disposeCommandChanges()
disposeSessionEvents()
disposeStatus()
disposeError()
@@ -1317,6 +1367,12 @@ export function createTuiChat(
} catch (error: unknown) {
disposed = true
detachListeners()
+ void commandFiber.dispose().catch(
+ /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
+ (cleanupError: unknown) => {
+ ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
+ },
+ )
clearStatus()
disposeUserInteraction()
ui.stop()
@@ -1327,6 +1383,7 @@ export function createTuiChat(
async dispose(): Promise {
detachListeners()
await shutdown(false)
+ await commandFiber.dispose()
},
}
}
diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts
index 96d0570921..c24577b7fd 100644
--- a/packages/ui/tui/tests/harness.ts
+++ b/packages/ui/tui/tests/harness.ts
@@ -1,6 +1,7 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
+import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -47,6 +48,7 @@ export async function createTuiTestHarness {
const unwrapped = loader.unwrapExports(tui) as Record
expect(unwrapped).toBe(tui)
expect(unwrapped.name).toBe('ui-tui')
- expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
+ expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
index 05809dea0c..5006083358 100644
--- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
+++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
-cursor visible column=0 viewportRow=22 bufferRow=22
+cursor visible column=0 viewportRow=29 bufferRow=29
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,37 @@ buffer
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
-10| " /help /clear /cancel /reasoning /tools /redraw /exit "
- style 1-52 fg=bright-black
-11|
-12| " Unknown command: /unknown-advanced-command "
- style 1-42 fg=yellow
-13|
-14| " provider stream failed after partial output "
+10| " "
+11| " /cancel — Cancel the active turn "
+ style 1-32 fg=bright-black
+12| " /clear — Clear the transcript view (session history is unchanged) "
+ style 1-65 fg=bright-black
+13| " /exit — Exit after the active turn reaches idle "
+ style 1-47 fg=bright-black
+14| " /help — Show keyboard shortcuts and commands "
+ style 1-44 fg=bright-black
+15| " /reasoning — Toggle reasoning blocks "
+ style 1-36 fg=bright-black
+16| " /redraw — Invalidate components and redraw the terminal "
+ style 1-55 fg=bright-black
+17| " /tools — Expand or collapse all tool cards "
+ style 1-42 fg=bright-black
+18|
+19| " provider stream failed after partial output "
style 1-43 fg=red
-15|
-16| " The previous process ended during this turn. "
+20|
+21| " The previous process ended during this turn. "
style 1-44 fg=yellow
-17| "────────────────────────────────────────────────────────────────────────────────────────────"
+22|
+23| " Unknown command: /unknown-advanced-command "
+ style 1-42 fg=yellow
+24| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
-18| " "
+25| " "
style 1-1 inverse
-19| "────────────────────────────────────────────────────────────────────────────────────────────"
+26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
-20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
+27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 59-91 dim
-21-31|
+28-31|
diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
index fccfca604b..4a99d7e9e7 100644
--- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
+++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
-cursor hidden column=1 viewportRow=18 bufferRow=18
+cursor hidden column=1 viewportRow=25 bufferRow=25
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,37 @@ buffer
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
-10| " /help /clear /cancel /reasoning /tools /redraw /exit "
- style 1-52 fg=bright-black
-11|
-12| " Unknown command: /unknown-advanced-command "
- style 1-42 fg=yellow
-13|
-14| " provider stream failed after partial output "
+10| " "
+11| " /cancel — Cancel the active turn "
+ style 1-32 fg=bright-black
+12| " /clear — Clear the transcript view (session history is unchanged) "
+ style 1-65 fg=bright-black
+13| " /exit — Exit after the active turn reaches idle "
+ style 1-47 fg=bright-black
+14| " /help — Show keyboard shortcuts and commands "
+ style 1-44 fg=bright-black
+15| " /reasoning — Toggle reasoning blocks "
+ style 1-36 fg=bright-black
+16| " /redraw — Invalidate components and redraw the terminal "
+ style 1-55 fg=bright-black
+17| " /tools — Expand or collapse all tool cards "
+ style 1-42 fg=bright-black
+18|
+19| " provider stream failed after partial output "
style 1-43 fg=red
-15|
-16| " The previous process ended during this turn. "
+20|
+21| " The previous process ended during this turn. "
style 1-44 fg=yellow
-17| "────────────────────────────────────────────────────────────────────────────────────────────"
+22|
+23| " Unknown command: /unknown-advanced-command "
+ style 1-42 fg=yellow
+24| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
-18| " "
+25| " "
style 1-1 inverse
-19| "────────────────────────────────────────────────────────────────────────────────────────────"
+26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
-20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
+27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 59-91 dim
-21-31|
+28-31|
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index c0a916e4f0..aa2ff5510e 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -499,6 +500,108 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(disposedAgent)
})
+ it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
+ const result = await setup()
+ const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
+ kind: 'success' as const,
+ text: `PLUGIN:${rawInput}`,
+ }))
+ result.ctx.commands.register({
+ name: 'plugin-check',
+ description: 'Run a plugin command',
+ input: { hint: '' },
+ handler,
+ })
+ result.ctx.commands.register({
+ name: 'plugin-fail',
+ description: 'Fail a plugin command',
+ handler: () => { throw new Error('plugin command exploded') },
+ })
+
+ result.terminal.send('/plugin-check value ')
+ result.terminal.send('\r')
+ await tick()
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ const invocation = handler.mock.calls[0]?.[0]
+ expect(invocation?.agent).toBe(result.agent)
+ // pi-tui's Editor owns terminal-line normalization and removes trailing
+ // spaces before onSubmit; the registry preserves the adapter-delivered line.
+ expect(invocation?.rawInput).toBe(' value')
+ expect(result.terminal.output).toContain('PLUGIN: value')
+ result.terminal.send('/plugin-fail')
+ result.terminal.send('\r')
+ await tick()
+ expect(result.terminal.output).toContain('Command failed: plugin command exploded')
+ result.terminal.send('/help')
+ result.terminal.send('\r')
+ await tick()
+ expect(result.terminal.output).toContain('/plugin-check — Run a plugin command')
+ expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
+
+ await result.controller.dispose()
+ expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
+ 'plugin-check',
+ 'plugin-fail',
+ ])
+ await result.ctx.fiber.dispose()
+ })
+
+ it('aborts an in-flight plugin command during TUI disposal', async () => {
+ const result = await setup()
+ let started!: () => void
+ const ready = new Promise((resolve) => { started = resolve })
+ let commandSignal: AbortSignal | undefined
+ result.ctx.commands.register({
+ name: 'wait-plugin',
+ description: 'Wait until disposal',
+ handler: ({ signal }) => {
+ commandSignal = signal
+ started()
+ return new Promise((resolve) => {
+ signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
+ })
+ },
+ })
+
+ result.terminal.send('/wait-plugin')
+ result.terminal.send('\r')
+ await ready
+ await result.controller.dispose()
+
+ expect(commandSignal?.aborted).toBe(true)
+ expect(result.terminal.output).not.toContain('late result')
+ await result.ctx.fiber.dispose()
+ })
+
+ it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
+ const result = await setup()
+ let started!: () => void
+ const ready = new Promise((resolve) => { started = resolve })
+ let resolveCommand!: (result: { kind: 'success'; text: string }) => void
+ result.ctx.commands.register({
+ name: 'late-success',
+ description: 'Resolve while the TUI closes',
+ handler: () => new Promise((resolve) => {
+ resolveCommand = resolve
+ started()
+ }),
+ })
+
+ result.terminal.send('/late-success')
+ result.terminal.send('\r')
+ await ready
+ resolveCommand({ kind: 'success', text: 'must not render after disposal' })
+ // Let the command boundary accept the result before disposal, but leave the
+ // TUI continuation queued so the success-side disposal guard owns the race.
+ await Promise.resolve()
+ await result.controller.dispose()
+ await tick()
+
+ expect(result.terminal.output).not.toContain('must not render after disposal')
+ await result.ctx.fiber.dispose()
+ })
+
it('cancels before /exit while running and handles agent errors/disposal', async () => {
const result = await setup({ status: 'running' })
result.terminal.send('/exit')
@@ -881,6 +984,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
@@ -899,6 +1003,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -927,6 +1032,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -954,6 +1060,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
@@ -974,6 +1081,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('failed-start-session'))
@@ -986,6 +1094,8 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
.toThrow('terminal startup failed')
+ await tick()
+ expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
@@ -1003,6 +1113,7 @@ describe('terminal mounting', () => {
it('throws when createTuiChat is called without the configured agent', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json
index 0c9e89e9e5..62cfef1a14 100644
--- a/packages/ui/tui/tsconfig.json
+++ b/packages/ui/tui/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../../core/tools"
},
+ {
+ "path": "../commands"
+ },
{
"path": "../user-interaction"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1fef497a14..5c3bc3ea15 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -722,6 +722,9 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../../ui/app-boot
+ '@deepseek-ai/dsh-commands':
+ specifier: workspace:^
+ version: link:../../ui/commands
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
@@ -880,6 +883,9 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../../ui/app-boot
+ '@deepseek-ai/dsh-commands':
+ specifier: workspace:^
+ version: link:../../ui/commands
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -2175,6 +2181,9 @@ importers:
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../../bash/bash-local
+ '@deepseek-ai/dsh-commands':
+ specifier: workspace:^
+ version: link:../commands
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
@@ -2245,6 +2254,21 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+ packages/ui/commands:
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-scope':
+ specifier: workspace:^
+ version: link:../../core/scope
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
packages/ui/jsonrpc:
dependencies:
schemastery:
@@ -2346,6 +2370,9 @@ importers:
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
+ '@deepseek-ai/dsh-commands':
+ specifier: workspace:^
+ version: link:../commands
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -2703,6 +2730,9 @@ importers:
'@deepseek-ai/dsh-code-runtime-worker':
specifier: workspace:^
version: link:../../packages/code-runtime/code-runtime-worker
+ '@deepseek-ai/dsh-commands':
+ specifier: workspace:^
+ version: link:../../packages/ui/commands
'@deepseek-ai/dsh-compact':
specifier: workspace:^
version: link:../../packages/compact/compact
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index a9405ddd80..b35c38b032 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -18,6 +18,7 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
+ "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index d5cb0ed571..ea0474b48e 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -77,6 +77,10 @@ export const LINK_MAP: Record = {
GoalChanged: 'goal.md',
GoalRef: 'goal.md',
GoalView: 'goal.md',
+ CommandDefinition: 'commands.md',
+ CommandDescriptor: 'commands.md',
+ CommandResult: 'commands.md',
+ CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 4d4779e7e7..29c21c0969 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -153,6 +153,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-ask-user', 'tui', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
+ {
+ key: 'commands',
+ pkg: 'commands',
+ title: 'Human command registry',
+ mode: 'core',
+ consumers: ['tui', 'acp'],
+ note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
+ },
{
key: 'skills',
pkg: 'skill',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index eb507d1c35..5cb84266da 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -39,6 +39,13 @@
{ "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandResult", "source": "packages/ui/commands/src/index.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDescriptor", "source": "packages/ui/commands/src/index.ts" },
+ { "doc": "docs/core-data-structures/commands.md", "symbol": "ParsedCommand", "source": "packages/ui/commands/src/index.ts" },
+
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" },
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 71d66ef8b9..19f4e9029e 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -25,6 +25,7 @@
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
+ { "path": "./packages/ui/commands" },
{ "path": "./packages/goal/goal" },
{ "path": "./packages/goal/tool-goal" },
{ "path": "./packages/goal/goal-session" },
diff --git a/tsconfig.json b/tsconfig.json
index 289fba3590..8e36c20f00 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,6 +38,7 @@
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
+ { "path": "./packages/ui/commands" },
{ "path": "./packages/goal/goal" },
{ "path": "./packages/goal/tool-goal" },
{ "path": "./packages/goal/goal-session" },
diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md
new file mode 100644
index 0000000000..3b92682dac
--- /dev/null
+++ b/website/zh-CN/api/harness/commands.md
@@ -0,0 +1,91 @@
+
+
+# ctx.commands
+
+`CommandService` — provided by `@deepseek-ai/dsh-commands`.
+
+Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207)
+
+### ctx.commands.register(definition)
+
+```ts website-api
+/**
+ * Register a global or calling-agent-scoped command.
+ * @param definition - discovery metadata and direct UI handler.
+ * @returns the exact effect disposer that unregisters this definition.
+ */
+register(definition: CommandDefinition): () => void
+```
+
+Register a global or calling-agent-scoped command.
+
+- `definition` — discovery metadata and direct UI handler.
+
+**Returns** the exact effect disposer that unregisters this definition.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220)
+
+### ctx.commands.list(agent)
+
+```ts website-api
+/**
+ * List the effective immutable command descriptors for one agent.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @returns name-sorted descriptors after scoped shadowing.
+ */
+list(agent: Agent): readonly CommandDescriptor[]
+```
+
+List the effective immutable command descriptors for one agent.
+
+- `agent` — exact receiving agent and scoped-layer key.
+
+**Returns** name-sorted descriptors after scoped shadowing.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247)
+
+### ctx.commands.find(agent, name)
+
+```ts website-api
+/**
+ * Resolve one effective command definition.
+ * @param agent - exact receiving agent and scoped-layer key.
+ * @param name - command name without a slash.
+ * @returns the scoped shadow or global definition.
+ */
+find(agent: Agent, name: string): CommandDefinition | undefined
+```
+
+Resolve one effective command definition.
+
+- `agent` — exact receiving agent and scoped-layer key.
+- `name` — command name without a slash.
+
+**Returns** the scoped shadow or global definition.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260)
+
+### ctx.commands.execute(agent, line, signal)
+
+```ts website-api
+/**
+ * Parse and execute a known command without sending it to the model.
+ * @param agent - exact receiving agent.
+ * @param line - complete slash-command line.
+ * @param signal - cancellation signal owned by the UI request.
+ * @returns a detached result, or `undefined` when syntax or name does not resolve.
+ */
+async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise
+```
+
+Parse and execute a known command without sending it to the model.
+
+- `agent` — exact receiving agent.
+- `line` — complete slash-command line.
+- `signal` — cancellation signal owned by the UI request.
+
+**Returns** a detached result, or `undefined` when syntax or name does not resolve.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271)