Bläddra i källkod

fix(plan): accept image-only plan requests

creatixchu 1 månad sedan
förälder
incheckning
51fa8da8a3

+ 2 - 2
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
-2026-08-17-command-image-attachment-envelope.md: f651658ef0b655d6d190d25f54c2afaa088ad7be
-2026-08-17-command-image-attachment-envelope.zh.md: dcf2f72feb6dc4c1a158acd6e66924f3c46f1b3e
+2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9
+2026-08-17-command-image-attachment-envelope.zh.md: bb135d218f156aaa36e3f9f52ed36019b68b56c3

+ 2 - 2
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md

@@ -20,7 +20,7 @@ The submission envelope is modeled end to end, and every command route either co
 
 **Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
 
-**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place.
+**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan <message>` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit.
 
 **Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
 
@@ -41,6 +41,6 @@ Registry executor enforcement, admission failure settlement, and frozen invocati
 
 - No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike.
 - The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly.
-- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), billed like any image prompt.
+- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt.
 - Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary.
 - "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written".

+ 2 - 2
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md

@@ -20,7 +20,7 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片
 
 **执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
 
-**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。
+**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan <message>` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。
 
 **composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
 
@@ -41,6 +41,6 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片
 
 - 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。
 - commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。
-- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),计费与任何图片提示词相同。
+- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。
 - 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。
 - 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。

+ 20 - 3
apps/web/tests/command-image-envelope.snapshot.ts

@@ -3,9 +3,10 @@
 // bundles via AppWebEntry, keyless FixtureApiClient transport): an enter
 // submission carrying composer images resolves only through a command whose
 // descriptor declares `input.images`. A non-declaring command refuses with
-// one composer error banner and everything retained; a declaring command consumes
-// the images — serialized through the real draft-image chain into the
-// commands/execute payload — and clears the composer on success.
+// one composer error banner and everything retained; a declaring command
+// consumes the images — serialized through the real draft-image chain into
+// the commands/execute payload — and clears the composer on success, including
+// when the image is the whole `/plan` task.
 import { fireEvent, screen, waitFor } from '@testing-library/react'
 import { expect, it } from 'vitest'
 import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
@@ -78,3 +79,19 @@ it('consumes images through a declaring command and clears the composer on succe
     expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
   }, { timeout: 5_000 })
 })
+
+it('submits a bare /plan with an image as an image-only plan request', async () => {
+  mountAssembledApp()
+  const textarea = await freshComposer()
+  await pasteImage(textarea, 'plan-task.png')
+
+  fireEvent.change(textarea, { target: { value: '/plan' } })
+  fireEvent.keyDown(textarea, { key: 'Enter' })
+
+  await waitFor(() => {
+    expect(textarea.value).toBe('')
+    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+  }, { timeout: 5_000 })
+  expect([...document.querySelectorAll('[role="alert"]')]
+    .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)
+})

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 docs/config-catalog.md
-config-catalog.md: 6759d4b8e953c2a147a0441be21a25e0920cc1df
-config-catalog.zh.md: e59ad1ac07f46b7cb044a83012aa97d1a78f769b
+config-catalog.md: fc14407107212b2b5ad4209cc755d7d586c066d0
+config-catalog.zh.md: 5c2f5048d4ba42d19418969bc8be828850b42618

+ 1 - 1
docs/config-catalog.md

@@ -1372,7 +1372,7 @@ export interface PlanModeConfig {
 }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-pwsh-local"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -1374,7 +1374,7 @@ export interface PlanModeConfig {
 }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-pwsh-local"></a>
 

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 docs/persistence-catalog.md
-persistence-catalog.md: c78c6c9b7c116b5ea545a6ecb6e0f5c9013a53a7
-persistence-catalog.zh.md: b787c8c30e0d695246db15372b639bd5bde44c07
+persistence-catalog.md: d290d40ade2773e591cf07235a64a150b52bdcd2
+persistence-catalog.zh.md: a2340f1226a01a8a797d576d64a667ce2fa58da6

+ 1 - 1
docs/persistence-catalog.md

@@ -527,7 +527,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/
 'plan/mode': { active: boolean }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

+ 1 - 1
docs/persistence-catalog.zh.md

@@ -529,7 +529,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'plan/mode': { active: boolean }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

+ 2 - 2
docs/subsystems/plan.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 docs/subsystems/plan.md
-plan.md: 4e6eb98e7c7cce295feeed0150984934f1a853e5
-plan.zh.md: f8236e6cbeca841bdab630aa831e844cc68179a0
+plan.md: 1f6863a24aa56773430be904e5a27c27384c9bff
+plan.zh.md: 056bce946b608876ac958f2d33d871e9622c7187

+ 1 - 1
docs/subsystems/plan.md

@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
 
 Types: [Agent](core.md)
 
-Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
docs/subsystems/plan.zh.md

@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
 
 Types: [Agent](core.md)
 
-Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 17 - 9
packages/client/connection/src/client/fixture.ts

@@ -742,26 +742,34 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
 }
 
 /**
- * Fixture parallel of the plan unit's double-event fold: `command/run`
- * records named `plan` with recorded input set the wanted target (`off` →
- * false, else true); `plan/mode` commits and clears it. `wanted` is exposed
- * for the prompt boundary (the fixture's step/start parallel).
+ * Fixture parallel of the plan unit's lifecycle fold. The paired
+ * `command/done` retains successful plan selections and drops failures;
+ * `plan/mode` commits one. `wanted` is exposed for the prompt boundary (the
+ * fixture's step/start parallel).
  */
 function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
   let active = false
   let wanted: boolean | null = null
+  let running: { commandId: unknown; wanted: boolean } | null = null
   for (const event of log) {
     const item = event as unknown as { type: string; data?: Record<string, unknown> }
     if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
       const args = item.data['args']
       if (typeof args !== 'string') continue
-      wanted = args.trim() !== 'off'
+      running = { commandId: item.data['commandId'], wanted: args.trim() !== 'off' }
+    } else if (item.type === 'command/done'
+      && item.data !== undefined
+      && running !== null
+      && item.data['commandId'] === running.commandId) {
+      wanted = item.data['kind'] === 'success' && running.wanted !== active ? running.wanted : null
+      running = null
     } else if (item.type === 'plan/mode') {
       active = item.data?.['active'] === true
       wanted = null
     }
   }
-  return { active, pending: wanted !== null && wanted !== active, wanted }
+  const selected = running?.wanted ?? wanted
+  return { active, pending: selected !== null && selected !== active, wanted: selected }
 }
 
 /** The plan projection's wire view over the full log. */
@@ -1752,7 +1760,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       // executor's order (an unknown name answers undefined and logs no
       // lifecycle): the declaration rejection covers every known command
       // without `input.images`, and the two producer grammar rejections cover
-      // the declaring commands' carrier-less lines. The fixture stores no
+      // the declaring commands' control-only lines. The fixture stores no
       // bytes, so an accepted batch is acknowledged and dropped.
       const known = ['permission', 'goal', 'compact', 'echo', 'plan']
       if (images.length > 0 && name !== undefined && known.includes(name)) {
@@ -1760,8 +1768,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           ? `/${name} does not accept image attachments`
           : name === 'goal' && args.trim() === ''
             ? 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.'
-            : name === 'plan' && (args.trim() === '' || args.trim() === 'off')
-              ? 'Image attachments require a plan message: /plan <message>.'
+            : name === 'plan' && args.trim() === 'off'
+              ? 'Image attachments cannot accompany /plan off.'
               : undefined
         if (rejection !== undefined) {
           const commandId = `fx-cmd-${logOf(id).length}` as CommandId

+ 10 - 9
packages/client/connection/tests/fixture-commands.client.spec.ts

@@ -117,9 +117,12 @@ describe('createFixtureApi commands/skills', () => {
     const planMessage = await callRemote<{ result: { kind: string } } | undefined>(
       rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] })
     expect(planMessage?.result.kind).toBe('success')
+    const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>(
+      rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] })
+    expect(imageOnlyPlan?.result.kind).toBe('success')
   })
 
-  it('mirrors the producer grammar rejections for carrier-less declaring lines', async () => {
+  it('mirrors the producer grammar rejections for control-only declaring lines', async () => {
     const { rpc } = createFixtureFaces()
     const png = { mediaType: 'image/png', data: 'AA==' }
     const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
@@ -128,14 +131,12 @@ describe('createFixtureApi commands/skills', () => {
       kind: 'error',
       text: 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
     })
-    for (const line of ['/plan', '/plan off']) {
-      const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
-        rpc, 'commands/execute', { agentId: sid('fx-alpha'), line, images: [png] })
-      expect(refused?.result).toEqual({
-        kind: 'error',
-        text: 'Image attachments require a plan message: /plan <message>.',
-      })
-    }
+    const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
+      rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] })
+    expect(refused?.result).toEqual({
+      kind: 'error',
+      text: 'Image attachments cannot accompany /plan off.',
+    })
   })
 
   it('answers no execution for an unknown name even when images accompany it', async () => {

+ 2 - 2
packages/plan/plan-mode/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/plan/plan-mode/README.md
-README.md: 67783a9369339005ba748d5cfa929ceb6fef4a70
-README.zh.md: 28d505f5f2591de9774c0e5f6412d5570a81163a
+README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e
+README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf

+ 4 - 4
packages/plan/plan-mode/README.md

@@ -16,13 +16,13 @@ While active, `plan:policy` renders the configured `section`. The plugin always
 
 The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message.
 
-When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block, and an invocation whose attachments have no message carrier (`/plan` or `/plan off`) returns a direct error before any mode change so the composer keeps the images.
+When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block. Bare `/plan` with images steers an image-only user message, while `/plan off` with images returns a direct error before any mode change so the composer keeps them.
 
 The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary.
 
 ## Session projection
 
-When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
+When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
 
 ## Configuration
 
@@ -65,11 +65,11 @@ The section is stable within plan mode, but entering or leaving changes the syst
 
 #### What the model sees
 
-`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it.
+`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it.
 
 #### Token effect
 
-The optional message costs the same history tokens as submitting that text separately; bare `/plan` and `/plan off` add none. A narrated active exit adds the small retained switch notice.
+The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice.
 
 #### KV Cache effect
 

+ 4 - 4
packages/plan/plan-mode/README.zh.md

@@ -16,13 +16,13 @@
 
 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅——用户关闭请求,转而发言——会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
 
-组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前;附件没有消息载体的调用(`/plan` 或 `/plan off`)会在任何模式变更前直接返回错误,composer 保留图片。
+组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前。不带参数的 `/plan` 若附有图片,会 steer 一条只含图片的用户消息;`/plan off` 若附有图片,会在任何模式变更前直接返回错误,composer 保留图片。
 
 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
 
 ## 会话投影
 
-当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
+当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
 
 ## 配置
 
@@ -65,11 +65,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr
 
 #### 模型所见内容
 
-`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。
+`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。
 
 #### Token 影响
 
-可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。
+可选消息的历史 token 成本与单独提交该内容相同。不带图片和参数的 `/plan` 与 `/plan off` 不增加 token;不带参数但带图的 `/plan` 产生常规图片提示词成本。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。
 
 #### KV Cache 影响
 

+ 33 - 25
packages/plan/plan-mode/src/index.ts

@@ -34,6 +34,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
 import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
 // Type-only edge: resolves `ctx.commands` for the optional command child.
 import type {} from '@deepseek-ai/dsh-commands'
+import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
 // Type-only: resolves ctx.sessionProjections for the optional unit child.
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type { PlanProjection } from './types.ts'
@@ -138,14 +139,17 @@ export function foldPlanMode(events: readonly SessionEvent[], end = events.lengt
 }
 
 /**
- * Projection unit state: the logged mode plus the latest logged `/plan`
- * selection (`command/run`) not yet resolved by a `plan/mode` commit. Plain
- * JSON (persisted-cache precondition).
+ * Projection unit state: the logged mode, the latest successful `/plan`
+ * selection not yet resolved by a `plan/mode` commit, and an execution whose
+ * paired `command/done` has not settled. Plain JSON (persisted-cache
+ * precondition).
  */
 interface PlanUnitState {
   active: boolean
   /** The selection's target mode; null when no selection is outstanding. */
   wanted: boolean | null
+  /** The latest plan command awaiting its paired settlement. */
+  running: { commandId: CommandId; wanted: boolean } | null
 }
 
 /** Wire payload schema of the `plan` projection. */
@@ -232,12 +236,11 @@ export class PlanModeController extends Service {
       },
     })
 
-    // The plan projection unit (session-projection RFC): a pure double-event
-    // fold serving clients the whole {active, pending} value. `command/run`
-    // records the user's logged /plan selection (the handler calls `set()`
-    // before any failing path, so a failed handler cannot leave the recorded
-    // command without its plan selection); `plan/mode` records that selection
-    // and clears it. Pending is thereby a pure
+    // The plan projection unit (session-projection RFC): a pure event fold
+    // serving clients the whole {active, pending} value. `command/run`
+    // records the user's logged /plan selection, its paired `command/done`
+    // keeps only successful selections, and `plan/mode` records that
+    // selection and clears it. Pending is thereby a pure
     // replay quantity: host restarts, other tabs, and cold reads all recover
     // it from the log alone. The unit child activates only when a projection
     // registry is composed (headless assemblies stay unaffected).
@@ -245,23 +248,29 @@ export class PlanModeController extends Service {
       projectionCtx.sessionProjections.register<'plan', PlanUnitState>({
         key: 'plan',
         schema: planProjectionSchema,
-        init: () => ({ active: false, wanted: null }),
+        init: () => ({ active: false, wanted: null, running: null }),
         apply: (state, event) => {
           if (event.type === 'command/run' && event.data.name === 'plan') {
             if (event.data.args === undefined) return state
             const wanted = event.data.args.trim() !== 'off'
-            return wanted === state.wanted ? state : { active: state.active, wanted }
+            return { ...state, running: { commandId: event.data.commandId, wanted } }
+          }
+          if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {
+            const wanted = event.data.kind === 'success' && state.running.wanted !== state.active
+              ? state.running.wanted
+              : null
+            return { ...state, wanted, running: null }
           }
           if (event.type === 'plan/mode') {
-            return { active: event.data.active, wanted: null }
+            return { ...state, active: event.data.active, wanted: null }
           }
           return state
         },
-        view: state => ({
-          active: state.active,
-          pending: state.wanted !== null && state.wanted !== state.active,
-        }),
-        stateVersion: 1,
+        view: (state) => {
+          const wanted = state.running?.wanted ?? state.wanted
+          return { active: state.active, pending: wanted !== null && wanted !== state.active }
+        },
+        stateVersion: 2,
       })
     })
 
@@ -273,12 +282,8 @@ export class PlanModeController extends Service {
         input: { hint: '[off|message]', images: true },
         handler: ({ agent, rawInput, attachments }) => {
           const message = rawInput.trim()
-          if (message === 'off' || message === '') {
-            // Attachments ride the steered message; without one they have no
-            // model-visible carrier, so the composer must keep them.
-            if (attachments.length > 0) {
-              return { kind: 'error', text: 'Image attachments require a plan message: /plan <message>.' }
-            }
+          if (message === 'off' && attachments.length > 0) {
+            return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' }
           }
           if (message === 'off') {
             switch (this.set(agent, false)) {
@@ -298,9 +303,12 @@ export class PlanModeController extends Service {
             }
           }
           const outcome = this.set(agent, true)
-          if (message !== '') {
+          if (message !== '' || attachments.length > 0) {
             agent.steer(createUserMessage({
-              content: [...attachments, { type: 'text', text: message }],
+              content: [
+                ...attachments,
+                ...(message === '' ? [] : [{ type: 'text' as const, text: message }]),
+              ],
               source: { kind: 'user' },
             }))
           }

+ 5 - 4
packages/plan/plan-mode/src/types.ts

@@ -11,9 +11,10 @@
 /**
  * The plan projection's wire value. `active` is the logged state in force
  * (the last `plan/mode`, inactive before the first); `pending` is true while
- * a logged `/plan` selection (`command/run`) targets a state other than
- * `active` and no later `plan/mode` event has recorded that state. Capability
- * absence (plan-mode not composed) is the key's absence, never a value.
+ * a logged `/plan` selection targets a state other than `active`, has not
+ * failed through its paired `command/done`, and no later `plan/mode` event has
+ * recorded that state. Capability absence (plan-mode not composed) is the
+ * key's absence, never a value.
  */
 export interface PlanProjection {
   active: boolean
@@ -22,7 +23,7 @@ export interface PlanProjection {
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionMap {
-    /** Plan collaboration state folded from `command/run` (name `plan`) and `plan/mode` events. */
+    /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */
     plan: PlanProjection
   }
 }

+ 17 - 6
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -645,7 +645,7 @@ describe('/plan', () => {
     expect(foldPlanMode(agent.session.events)).toBe(false)
   })
 
-  it('rides image attachments on the steered plan message and refuses carriers without one', async () => {
+  it('steers image attachments with or without text and refuses them on /plan off', async () => {
     const ctx = await setup()
     await ctx.plugin(CommandRuntime)
     await new Promise(resolve => setImmediate(resolve))
@@ -693,11 +693,22 @@ describe('/plan', () => {
     const bareSteer = vi.fn()
     ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer
     expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result)
-      .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan <message>.' })
-    expect((await ctx.commands.execute(bareAgent, '/plan off', images, signal))?.result)
-      .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan <message>.' })
-    expect(bareSteer).not.toHaveBeenCalled()
-    expect(ctx.planMode.get(bareAgent)).toEqual({ active: false })
+      .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' })
+    expect(bareSteer).toHaveBeenCalledExactlyOnceWith({
+      id: expect.any(String) as unknown,
+      role: 'user',
+      content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }],
+      source: { kind: 'user' },
+    })
+    expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true })
+
+    const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true })
+    const offSteer = vi.fn()
+    ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer
+    expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result)
+      .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' })
+    expect(offSteer).not.toHaveBeenCalled()
+    expect(ctx.planMode.get(activeAgent)).toEqual({ active: true })
   })
 
   it('removes the contributed command when the plan-mode plugin is disposed', async () => {

+ 26 - 10
packages/plan/plan-mode/tests/projection.spec.ts

@@ -1,9 +1,10 @@
 /**
  * The `plan` projection unit (session-projection RFC's complete example): a
- * double-event fold over the session log. `command/run` records named `plan`
- * with recorded input set the wanted target (`off` → false, anything else
- * → true); `plan/mode` commits and clears it. `view` reports pending only
- * while an outstanding selection differs from the logged state.
+ * event fold over the session log. `command/run` records named `plan` with
+ * recorded input set the candidate target (`off` → false, anything else →
+ * true); `command/done` keeps successful candidates and drops failures;
+ * `plan/mode` commits and clears a selection. `view` reports pending only while
+ * an outstanding selection differs from the logged state.
  * Pending is thereby a pure replay quantity — a cold fold answers it without
  * the service's in-memory intent. Composition without plan-mode has no `plan`
  * key; unloading the fiber removes it (HMR safety).
@@ -47,13 +48,20 @@ async function harness(withPlanMode: boolean): Promise<Bench> {
 }
 
 /** Append one logged /plan selection record (the executor's command/run shape). */
-function runPlanCommand(session: Session, args: string, index: number): void {
+function runPlanCommand(session: Session, args: string, index: number): CommandId {
+  const commandId = CommandId(`plan-proj-${String(index)}`)
   session.append('command/run', {
-    commandId: CommandId(`plan-proj-${String(index)}`),
+    commandId,
     name: 'plan',
     args,
     source: { kind: 'user' },
   })
+  return commandId
+}
+
+/** Append the paired settlement for one projected plan command. */
+function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void {
+  session.append('command/done', { commandId, kind })
 }
 
 /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */
@@ -71,15 +79,23 @@ describe('plan projection unit', () => {
 
   it('a logged /plan selection reads pending until plan/mode records it', async () => {
     const bench = await harness(true)
-    runPlanCommand(bench.session, '', 0)
+    const commandId = runPlanCommand(bench.session, '', 0)
     expect(bench.values().plan).toEqual({ active: false, pending: true })
-    // A repeated identical selection returns the same state reference (no frame).
-    runPlanCommand(bench.session, '', 1)
+    settlePlanCommand(bench.session, commandId, 'success')
     expect(bench.values().plan).toEqual({ active: false, pending: true })
     commitPlanMode(bench.session, true, 0)
     expect(bench.values().plan).toEqual({ active: true, pending: false })
   })
 
+  it('drops a plan selection when its command settles with an error', async () => {
+    const bench = await harness(true)
+    commitPlanMode(bench.session, true, 0)
+    const commandId = runPlanCommand(bench.session, 'off', 0)
+    expect(bench.values().plan).toEqual({ active: true, pending: true })
+    settlePlanCommand(bench.session, commandId, 'error')
+    expect(bench.values().plan).toEqual({ active: true, pending: false })
+  })
+
   it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => {
     const bench = await harness(true)
     commitPlanMode(bench.session, true, 0)
@@ -128,7 +144,7 @@ describe('plan projection unit', () => {
     // memory involved, the fold alone answers {active:false, pending:true}.
     const cold = await harness(true)
     for (const event of bench.session.events) {
-      if (event.type === 'command/run' || event.type === 'plan/mode') {
+      if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') {
         cold.session.append(event.type, event.data)
       }
     }