Przeglądaj źródła

feat(gui): host goal handlers and slash command dispatch on web prompts

- web host boots CommandService + command-goal; session.prompt intercepts
  a lone text block starting with '/' and executes it as a command
  (unknown -> RPC unknown-command, usage error -> RPC command-error, so
  the composer restores the draft and shows the error strip) instead of
  sending it to the model — symmetric with the ACP adapter
- prompt response carries command result text on the wire for future UI
- api-proxy also lands the host-side goals RPC handlers
_Kerman 2 miesięcy temu
rodzic
commit
158b8a8ead

+ 6 - 0
.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.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-22-web-slash-command-dispatch.md: 11f6c696c7947531ce147f7bf9e48a6f9bfe2a92
+2026-07-22-web-slash-command-dispatch.zh.md: b08835112926f03f817b3b06e7594f3e9d2e237e

+ 39 - 0
.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.md

@@ -0,0 +1,39 @@
+# Agent Note: Web slash-command dispatch
+
+Status: implemented
+
+English | [中文](2026-07-22-web-slash-command-dispatch.zh.md)
+
+## Problem
+
+The [human `/goal` command](2026-07-19-human-goal-command.md) shipped with two dispatch points: the TUI and ACP adapters intercept leading-`/` prompts and execute them through the command registry without a model turn. The web UI had no interception anywhere on its path — composer, client runtime, RPC, and host all passed the text through — so `/goal fix the flaky test` reached the model as an ordinary user message. The command cost a model turn, produced no deterministic state change, and could be reinterpreted, while the web host composition never even mounted the command registry or the `/goal` producer.
+
+## Decision
+
+The web host dispatches slash commands at its own adapter boundary, symmetric with ACP: inside the api-proxy `sessions.prompt` handler in `packages/host/runtime/src/api-proxy.ts`, before `agent.send`/`agent.steer`. `bootHost` mounts `CommandService` and `command-goal` right after the goal stack, so the registry and producer share the host composition's lifecycle.
+
+A prompt whose content is exactly one text block starting with `/` is the command candidate. The web composer only ever sends that shape, and multi-block content is never flattened into a command line. Dispatch is mode-agnostic: commands consume no turn, so queue and steer execute identically and neither reaches the agent.
+
+The execution outcome maps onto the RPC result the composer choreography already understands. A successful command returns ok with `{ accepted: true, command: { kind: 'success', text? } }` — the draft stays cleared. A usage or state error (bare `/goal edit`, a redundant `/goal pause`) returns an RPC error with the new code `command-error`, and an unrecognized name returns `unknown-command`; both make the client restore the composer's draft and show the message on the error strip, which is the right UX for a malformed command. The two codes are new rows in `RpcErrorDetailsMap` with matching error-schema branches, and the `session.prompt` response value gains the optional command slot in both the signature layer and the zod schema.
+
+The success text travels on the wire but the web UI does not render it yet; the state change — the goal bar appearing, a paused goal resuming — is the feedback. Handler defects still propagate out of `commands.execute` and become carrier-level 500s, matching the api-proxy rule that implementations never throw business errors.
+
+## Testing
+
+`packages/host/runtime/tests/api-proxy-command.spec.ts` mounts the real command registry, agent registry, goal service, and `/goal` producer against a structural idle agent whose `send`/`steer` calls are recorded. It covers `/goal <objective>` creating the goal with the command slot carried and no model turn, mode-agnostic dispatch under `steer`, the `unknown-command` (with and without trailing input) and `command-error` RPC errors, a registered command whose success carries no text, a non-command prompt reaching `agent.send` unchanged, and degenerate shapes — multi-block content, an empty array, a single non-text block — never being treated as a command. The existing `rpc-schemas.spec.ts` gates the extended wire shape.
+
+## Alternatives considered
+
+- **Intercept in the browser client** — rejected because the command registry and goal domain live in the host process; the client has no plugin runtime, and duplicating command ownership client-side would drift from the host's composition.
+- **Render command output as a synthetic assistant message** — rejected because fabricating a model-visible event would violate the model-visible ⟺ logged rule and invent a second audit record; the wire carries the text for a future dedicated surface instead.
+- **Dispatch only in queue mode** — rejected because commands consume no turn, so mode is meaningless to them; ACP likewise executes commands outside its prompt-turn machinery.
+- **Flatten multi-block content into a command line like ACP** — rejected because the web composer sends exactly one text block; a lossy flattening path would have no caller.
+
+## Consequences
+
+- `/goal` and any future registered command work from the web composer without a model turn; unknown or malformed commands restore the draft with an error instead of reaching the model.
+- The RPC error vocabulary gains `command-error` and `unknown-command`, and `session.prompt` responses may carry a command slot.
+- Command success text is on the wire but unrendered in the web UI; a dedicated output surface remains deferred.
+- Multi-block prompts are never command candidates, so richer composer content cannot accidentally dispatch.
+- Commands dispatched in the web host run under a fresh, never-aborted AbortController: an async command (the registry permits them) cannot be cancelled from the UI, and `session.cancel` does not reach it (ACP keeps the controller on the session record for exactly this).
+- The single-block predicate rejects any lone text block starting with `/` that is not a registered command (e.g. `/etc/hosts`, `/Goal`) as `unknown-command` rather than letting it reach the model — deliberate and symmetric with ACP, mitigated by the draft restore.

+ 39 - 0
.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: Web 斜杠命令分发
+
+Status: implemented
+
+[English](2026-07-22-web-slash-command-dispatch.md) | 中文
+
+## 问题
+
+[面向人类的 `/goal` 命令](2026-07-19-human-goal-command.md)交付时有两个分发点:TUI 与 ACP 适配器拦截以 `/` 开头的提示词,通过命令注册表执行而不消耗模型轮次。Web UI 的整条路径——输入框、客户端运行时、RPC 与宿主——都没有任何拦截,因此 `/goal fix the flaky test` 会作为普通用户消息到达模型。该命令消耗一次模型轮次、不产生确定性的状态改变、还可能被重新解释,而 Web 宿主组合甚至没有挂载命令注册表和 `/goal` 生产方。
+
+## 决策
+
+Web 宿主在自己的适配器边界分发斜杠命令,与 ACP 对称:在 `packages/host/runtime/src/api-proxy.ts` 的 api-proxy `sessions.prompt` 处理器内、`agent.send`/`agent.steer` 之前。`bootHost` 在目标栈之后紧接着挂载 `CommandService` 与 `command-goal`,使注册表与生产方共享宿主组合的生命周期。
+
+内容恰好是一个以 `/` 开头的文本块的提示词才是命令候选。Web 输入框只会发送这种形态,多块内容绝不会被拍平成命令行。分发与模式无关:命令不消耗轮次,因此 queue 与 steer 的执行完全一致,且都不会到达 agent。
+
+执行结果映射到输入框编排逻辑已经理解的 RPC 结果上。命令成功时返回 ok,携带 `{ accepted: true, command: { kind: 'success', text? } }`——草稿保持已清空状态。用法或状态错误(单独的 `/goal edit`、多余的 `/goal pause`)返回新错误码 `command-error` 的 RPC 错误,未识别的名称返回 `unknown-command`;两者都会让客户端恢复输入框草稿并在错误条上显示消息,这正是畸形命令应有的 UX。这两个错误码是 `RpcErrorDetailsMap` 中的新行,并配有对应的错误 schema 分支;`session.prompt` 的响应值在签名层与 zod schema 中都增加了可选的 command 槽位。
+
+成功文本会在线路上传输,但 Web UI 暂时不渲染它;状态改变——目标栏出现、已暂停目标恢复——就是反馈。处理器缺陷仍会传播出 `commands.execute` 并成为承载层 500,这符合 api-proxy 的规则:实现绝不抛出业务错误。
+
+## 测试
+
+`packages/host/runtime/tests/api-proxy-command.spec.ts` 针对一个记录了 `send`/`steer` 调用的结构性空闲 agent,挂载真实的命令注册表、agent 注册表、目标服务与 `/goal` 生产方。它覆盖:`/goal <objective>` 创建目标、携带 command 槽位且不经过模型轮次;`steer` 下与模式无关的分发;`unknown-command`(带与不带尾随输入)与 `command-error` RPC 错误;成功但不带文本的已注册命令;非命令提示词原样到达 `agent.send`;退化形态——多块内容、空数组、单个非文本块——绝不会被当作命令。现有的 `rpc-schemas.spec.ts` 对扩展后的线路形态进行门禁。
+
+## 考虑过的替代方案
+
+- **在浏览器客户端拦截**——不予采纳,因为命令注册表与目标领域位于宿主进程;客户端没有插件运行时,在客户端复制命令所有权会与宿主的组合发生偏差。
+- **把命令输出渲染为合成的助手消息**——不予采纳,因为凭空制造模型可见事件会违反“模型可见 ⟺ 已记录”规则,并引入第二份审计记录;线路上携带文本,留待未来的专用表面渲染。
+- **仅在 queue 模式下分发**——不予采纳,因为命令不消耗轮次,模式对它们没有意义;ACP 同样在其提示词轮次机制之外执行命令。
+- **像 ACP 那样把多块内容拍平成命令行**——不予采纳,因为 Web 输入框恰好只发送一个文本块;有损的拍平路径不会有调用方。
+
+## 后果
+
+- `/goal` 以及未来注册的任何命令都可以在 Web 输入框中使用且不消耗模型轮次;未知或畸形命令会恢复草稿并报错,而不是到达模型。
+- RPC 错误词汇表新增 `command-error` 与 `unknown-command`,`session.prompt` 响应可以携带 command 槽位。
+- 命令成功文本已在线路上但尚未在 Web UI 渲染;专用输出表面仍然延期。
+- 多块提示词绝不会成为命令候选,因此更丰富的输入框内容不会意外触发分发。
+- Web 宿主分发的命令运行在一个新建的、永远不会被中止的 AbortController 下:异步命令(注册表允许异步命令)无法从 UI 取消,`session.cancel` 也触及不到它(ACP 正是为此把 controller 保存在会话记录上)。
+- 单块判定意味着任何以 `/` 开头但不是已注册命令的单独文本块(如 `/etc/hosts`、`/Goal`)都会以 `unknown-command` 被拒绝,而不会到达模型——这是有意为之,与 ACP 对称,并由草稿恢复机制缓解。

+ 1 - 1
docs/cordis-catalog/services.md

@@ -611,7 +611,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef
 
 Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
 
-Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:147`](../../packages/goal/goal/src/index.ts)
 
 ## `ctx.invariants` — `InvariantService`
 

+ 2 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -35,6 +35,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
   z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
+  z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
+  z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
 ]) as unknown as z.ZodType<RpcError>
 

+ 4 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -32,6 +32,10 @@ export interface RpcErrorDetailsMap {
   'bad-request': { issues: ZodIssue[] }
   'session-not-found': { sessionId: SessionId }
   'agent-busy': { reason: string }
+  /** A known slash command reported a usage/state error; the message is the command's own text. */
+  'command-error': {}
+  /** A leading-/ prompt named no registered command; the message names the token. */
+  'unknown-command': {}
   'internal': {}
 }
 

+ 5 - 1
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -94,9 +94,13 @@ export const sessionPromptRequestSchema = z.object({
   content: z.array(contentBlockSchema),
 }) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
 
-/** session.prompt response value. */
+/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
 export const sessionPromptValueSchema = z.object({
   accepted: z.literal(true),
+  command: z.object({
+    kind: z.literal('success'),
+    text: z.string().optional(),
+  }).optional(),
 }) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
 
 /** session.cancel request payload. */

+ 9 - 2
packages/host/apiproxy/src/api/sessions.ts

@@ -64,9 +64,16 @@ export interface SessionsApi {
   history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
   Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
 
-  /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
+  /**
+   * Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
+   * A prompt whose content is exactly one text block starting with '/' is a slash command: the host
+   * executes it through the command registry (mode-agnostic) and it is never sent to the model. A
+   * successful command returns ok with the command slot (its success text, when the command produced
+   * one — carried for future rendering; the state change is the feedback). A usage/state error is an
+   * RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
+   */
   prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
-  Promise<RpcResponse<{ accepted: true }>>
+  Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
 
   /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
   cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>

+ 8 - 0
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -31,11 +31,14 @@ describe('rpcErrorSchema', () => {
     expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
     expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
     expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
+    expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
+    expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
     expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
   })
 
   it('rejects a known code with missing details', () => {
     expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
+    expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
   })
 })
@@ -103,6 +106,11 @@ describe('sessions domain schemas', () => {
     expect(prompt.mode).toBe('queue')
     expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
     expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
+    // The command slot appears only when the prompt dispatched a slash command.
+    const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } })
+    expect(dispatched.command?.text).toBe('Goal set')
+    expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
+    expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
     expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
     expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
     expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })

+ 4 - 0
packages/host/runtime/package.json

@@ -40,7 +40,11 @@
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
+    "@deepseek-ai/dsh-command-goal": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compact-basic": "workspace:^",
+    "@deepseek-ai/dsh-goal": "workspace:^",
+    "@deepseek-ai/dsh-goal-session": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",

+ 141 - 0
packages/host/runtime/src/api-proxy.ts

@@ -11,8 +11,10 @@ import type { Context } from 'cordis'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-commands'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
+import type { GoalView } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 
@@ -104,6 +106,17 @@ function frame<F>(payload: F): RpcRequest<F> {
   return { rpcId: RpcId(randomUUID()), payload }
 }
 
+/**
+ * Slash-command candidate: the web composer sends exactly one text block, so
+ * only that exact shape dispatches; multi-block content is never flattened.
+ */
+function commandCandidate(content: ContentBlock[]): string | undefined {
+  const [first, ...rest] = content
+  if (first === undefined || rest.length > 0) return undefined
+  if (first.type !== 'text' || !first.text.startsWith('/')) return undefined
+  return first.text
+}
+
 /** SessionSummary projection for attached (in-memory) sessions. */
 function summarize(session: Session, running: boolean): SessionSummary {
   return {
@@ -208,6 +221,22 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
   return undefined
 }
 
+/** Project a server-side GoalView into the wire GoalView shape. */
+function goalView(g: import('@deepseek-ai/dsh-goal').GoalView): GoalView {
+  return {
+    id: g.id,
+    revision: g.revision,
+    objective: g.objective,
+    phase: g.phase,
+    ...(g.blockedReason !== undefined ? { blockedReason: g.blockedReason } : {}),
+    maxGoalRounds: g.maxGoalRounds,
+    roundsStarted: g.roundsStarted,
+    createdAt: g.createdAt,
+    updatedAt: g.updatedAt,
+    activation: g.activation,
+  }
+}
+
 /**
  * Thrown by the cold-resume path when the id names no servable session
  * (absent from the store, or a pre-project legacy log without a cwd).
@@ -318,6 +347,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         const found = await agentFor(sessionId)
         if ('error' in found) return err(request, found.error)
         const agent = found.agent
+        // Host-side slash-command dispatch (symmetric with the ACP adapter): a
+        // leading-/ single-text-block prompt executes through the command
+        // registry instead of reaching the model. Commands are mode-agnostic,
+        // so queue and steer dispatch identically.
+        const commandLine = commandCandidate(content)
+        if (commandLine !== undefined) {
+          // Unary handlers carry no request signal; the dispatch owns a fresh
+          // one (commands here are synchronous mutations, so nothing aborts it).
+          const result = await ctx.commands.execute(agent, commandLine, new AbortController().signal)
+          if (result === undefined) {
+            const space = commandLine.search(/\s/u)
+            const token = space === -1 ? commandLine : commandLine.slice(0, space)
+            return err(request, { code: 'unknown-command', message: `unknown command: ${token}`, details: {} })
+          }
+          // Usage/state errors travel as RPC errors so the client restores the
+          // composer's draft and shows the message on its error strip.
+          if (result.kind === 'error') {
+            return err(request, { code: 'command-error', message: result.text, details: {} })
+          }
+          return ok(request, {
+            accepted: true as const,
+            command: { kind: 'success' as const, ...result.text === undefined ? {} : { text: result.text } },
+          })
+        }
         // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
         const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
         try {
@@ -425,5 +478,93 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     respond(_message: ClientResponse): Promise<RpcReceipt> {
       return Promise.resolve({ accepted: false, reason: 'not-pending' })
     },
+
+    goals: {
+      async get(request) {
+        const { sessionId } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        const goal = ctx.goals.get(found.agent)
+        return ok(request, { goal: goal ? goalView(goal) : null })
+      },
+
+      async create(request) {
+        const { sessionId, objective, maxGoalRounds } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          const goal = ctx.goals.create(found.agent, {
+            objective,
+            ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
+          })
+          return ok(request, { goal: goalView(goal) })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+
+      async edit(request) {
+        const { sessionId, ref, objective, maxGoalRounds } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          const goal = ctx.goals.edit(found.agent, ref, {
+            ...(objective !== undefined ? { objective } : {}),
+            ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
+          })
+          return ok(request, { goal: goalView(goal) })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+
+      async pause(request) {
+        const { sessionId, ref } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          const goal = ctx.goals.pause(found.agent, ref)
+          return ok(request, { goal: goalView(goal) })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+
+      async resume(request) {
+        const { sessionId, ref } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          const goal = ctx.goals.resume(found.agent, ref)
+          return ok(request, { goal: goalView(goal) })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+
+      async complete(request) {
+        const { sessionId, ref } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          const goal = ctx.goals.complete(found.agent, ref)
+          return ok(request, { goal: goalView(goal) })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+
+      async clear(request) {
+        const { sessionId, ref } = request.payload
+        const found = await agentFor(sessionId as SessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          ctx.goals.clear(found.agent, ref)
+          return ok(request, { cleared: true as const })
+        } catch (error: unknown) {
+          return err(request, { code: 'internal', message: String(error), details: {} })
+        }
+      },
+    },
   }
 }

+ 11 - 0
packages/host/runtime/src/boot.ts

@@ -37,6 +37,10 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
 import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
 import SpillLocal from '@deepseek-ai/dsh-spill-local'
 import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
+import GoalService from '@deepseek-ai/dsh-goal'
+import * as goalSession from '@deepseek-ai/dsh-goal-session'
+import CommandService from '@deepseek-ai/dsh-commands'
+import * as commandGoal from '@deepseek-ai/dsh-command-goal'
 
 /** Options for bootHost — the assembly-layer composition knobs. */
 export interface BootHostOptions {
@@ -129,5 +133,12 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
   // Oversized tool output spills to session-scoped files (repl-agent budget).
   await ctx.plugin(SpillLocal, {})
   await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
+  // Goal service and automatic same-session continuation.
+  await ctx.plugin(GoalService, {})
+  await ctx.plugin(goalSession)
+  // Human slash commands: the registry plus the /goal producer; the api-proxy
+  // prompt path dispatches leading-/ single-text-block prompts through them.
+  await ctx.plugin(CommandService)
+  await ctx.plugin(commandGoal)
   return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
 }

+ 197 - 0
packages/host/runtime/tests/api-proxy-command.spec.ts

@@ -0,0 +1,197 @@
+/**
+ * Host-side slash-command dispatch in sessions.prompt: a leading-/
+ * single-text-block prompt executes through the command registry and never
+ * reaches the model — symmetric with the ACP adapter. Successful commands
+ * return ok with the command slot; usage errors and unknown names return RPC
+ * errors so the client restores the composer's draft. Non-command prompts
+ * still route to agent.send/steer.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
+import CommandService from '@deepseek-ai/dsh-commands'
+import GoalService from '@deepseek-ai/dsh-goal'
+import * as commandGoal from '@deepseek-ai/dsh-command-goal'
+import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { createApiProxy } from '../src/api-proxy.ts'
+
+interface Harness {
+  readonly ctx: Context
+  readonly agent: Agent
+  readonly session: Session
+  /** Content arguments of every agent.send/steer call, in order. */
+  readonly sent: ContentBlock[][]
+  readonly steered: ContentBlock[][]
+}
+
+/** Number the next balanced injection turn. */
+function nextTurn(session: Session): number {
+  return session.events.reduce(
+    (maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
+    0,
+  ) + 1
+}
+
+/** Build a live idle agent whose send/steer calls are recorded. */
+function stubAgent(id: string): { agent: Agent; session: Session; sent: ContentBlock[][]; steered: ContentBlock[][] } {
+  const session = new Session(SessionId(id))
+  const sent: ContentBlock[][] = []
+  const steered: ContentBlock[][] = []
+  let status: AgentStatus = 'idle'
+  const agent: Agent = {
+    id: session.id,
+    options: {},
+    session,
+    ctx: new Context(),
+    get status() { return status },
+    send(content) { sent.push(content) },
+    steer(content) { steered.push(content) },
+    inject(content: ContentBlock[], options?: InjectOptions) {
+      const source: MessageSource = options?.source ?? { kind: 'user' }
+      const turn = nextTurn(session)
+      session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
+      session.append('context/message', {
+        content,
+        source,
+        ...options?.meta === undefined ? {} : { meta: options.meta },
+      }, { surfaceOp: 'append' })
+      session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    },
+    cancel() { status = 'idle' },
+    whenIdle() { return Promise.resolve() },
+  }
+  return { agent, session, sent, steered }
+}
+
+/** Mount the real command registry, goal domain, and /goal producer. */
+async function harness(): Promise<Harness> {
+  const ctx = new Context()
+  await ctx.plugin(CommandService)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(GoalService)
+  await ctx.plugin(commandGoal)
+  const { agent, session, sent, steered } = stubAgent(`api-proxy-command-${Math.random()}`)
+  ctx.agents.register(agent)
+  return { ctx, agent, session, sent, steered }
+}
+
+let nextRpc = 1
+function request<P>(payload: P): RpcRequest<P> {
+  return { rpcId: RpcId(`command-${String(nextRpc++)}`), payload }
+}
+
+function promptPayload(test: Harness, text: string, mode: 'queue' | 'steer' = 'queue') {
+  const content: ContentBlock[] = [{ type: 'text', text }]
+  return request({ sessionId: test.session.id, mode, content })
+}
+
+describe('sessions.prompt slash-command dispatch', () => {
+  it('executes /goal <objective>: goal created, command slot carried, no model turn', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.sessions.prompt(promptPayload(test, '/goal fix the flaky test'))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.accepted).toBe(true)
+    expect(response.result.value.command?.kind).toBe('success')
+    expect(response.result.value.command?.text).toContain('Goal created')
+
+    const goal = test.ctx.goals.get(test.agent)
+    expect(goal?.objective).toBe('fix the flaky test')
+    // The prompt never reached the model: no send, no user/message event.
+    expect(test.sent).toEqual([])
+    expect(test.steered).toEqual([])
+    expect(test.session.events.filter(event => event.type === 'user/message')).toEqual([])
+  })
+
+  it('dispatches commands regardless of mode (steer prompt never steers)', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.sessions.prompt(promptPayload(test, '/goal', 'steer'))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.command?.text).toContain('No goal is currently set')
+    expect(test.sent).toEqual([])
+    expect(test.steered).toEqual([])
+  })
+
+  it('returns unknown-command for an unregistered name', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.sessions.prompt(promptPayload(test, '/bogus do something'))
+    expect(response.result.ok).toBe(false)
+    if (response.result.ok) throw new Error('unreachable')
+    expect(response.result.error.code).toBe('unknown-command')
+    expect(response.result.error.message).toBe('unknown command: /bogus')
+    expect(test.sent).toEqual([])
+
+    const bare = await api.sessions.prompt(promptPayload(test, '/bogus'))
+    expect(bare.result.ok).toBe(false)
+    if (!bare.result.ok) expect(bare.result.error.message).toBe('unknown command: /bogus')
+  })
+
+  it('carries a success without text when the command produced none', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    test.ctx.commands.register({ name: 'ping', description: 'test no-text success', handler: () => ({ kind: 'success' }) })
+
+    const response = await api.sessions.prompt(promptPayload(test, '/ping'))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.command).toEqual({ kind: 'success' })
+    expect(test.sent).toEqual([])
+  })
+
+  it('returns command-error for a usage error (bare /goal edit)', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.sessions.prompt(promptPayload(test, '/goal edit'))
+    expect(response.result.ok).toBe(false)
+    if (response.result.ok) throw new Error('unreachable')
+    expect(response.result.error.code).toBe('command-error')
+    expect(response.result.error.message).toContain('Goal editing requires a replacement objective')
+    expect(test.sent).toEqual([])
+  })
+
+  it('routes a non-command prompt to agent.send unchanged', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.sessions.prompt(promptPayload(test, 'hello there'))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.accepted).toBe(true)
+    expect('command' in response.result.value).toBe(false)
+    expect(test.sent).toEqual([[{ type: 'text', text: 'hello there' }]])
+  })
+
+  it('routes multi-block content starting with / to the model (never flattened)', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const content: ContentBlock[] = [{ type: 'text', text: '/goal not a command' }, { type: 'text', text: 'second' }]
+    const response = await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content }))
+    expect(response.result.ok).toBe(true)
+    expect(test.sent).toEqual([content])
+  })
+
+  it('routes degenerate content shapes to the model (empty array, single non-text block)', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const empty: ContentBlock[] = []
+    await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: empty }))
+    const nonText: ContentBlock[] = [{ type: 'reasoning', text: '/goal not a command' }]
+    await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: nonText }))
+    expect(test.sent).toEqual([empty, nonText])
+  })
+})

+ 12 - 0
packages/host/runtime/tsconfig.json

@@ -62,6 +62,15 @@
     {
       "path": "../../fs/tool-fs-search"
     },
+    {
+      "path": "../../goal/goal"
+    },
+    {
+      "path": "../../goal/goal-session"
+    },
+    {
+      "path": "../../goal/command-goal"
+    },
     {
       "path": "../../llm/token-meter"
     },
@@ -137,6 +146,9 @@
     {
       "path": "../../client/ui-conversation"
     },
+    {
+      "path": "../../ui/commands"
+    },
     {
       "path": "../../client/ui-trajectory"
     }