Bladeren bron

feat(mode): the session-mode core — logged per-agent policy state (@deepseek-ai/dsh-mode)

Plan mode's stage 1 (RFC 2026-07-07-plan-mode): a new packages/mode/ group
with one product package owning the mode/set SessionEventMap vocabulary
(log-only, non-surface, whole-value replace), the pure foldMode, and the
ctx.modes service (list/get/set). User flips are pending intents flushed
at turn/start / step/end — turn enclosure makes an idle append illegal —
with one coalesced context/message notice when the flushed mode differs
from what the last logged request header told the model; a folded mode
the config no longer defines reads as default plus one boundary notice.

Enforcement is two covering layers: a system-prompt/assemble wrapper
filters the RETURNED assembly's tools to the mode's allowlist (and shows
exit_plan_mode IFF the folded mode is plan) beside the mode:policy
section at order 50, and a tools/pre-execute gate denies deny-by-default
against the same allowlist, judging by the logged mode only. The default
mode is the absence of policy — assemblies stay byte-identical to a
no-dsh-mode deployment.

AgentOptions.mode (declaration-merged) seeds a child's initial mode
through the same flush on agent/created; the stdio app gains /mode
(print/switch, never sent to the model) over an opportunistic
ctx.get('modes'). Config is an explicit resolve step: the built-in plan
definition (read-only allowlist; bash/subagent excluded until the
sandbox family lands) merges unless overridden, 'default' as a key
throws at load, unknown names throw at set() time.
kingwl 2 maanden geleden
bovenliggende
commit
63ced3e0e2

+ 1 - 0
AGENTS.md

@@ -19,6 +19,7 @@ packages/    Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
   compact/     compaction seam + basic backend
   subagent/    subagent seam + spawn/fork/ACP backends + delegation tool
   todo/        the todo_write tool
+  mode/        session modes: plan mode as logged per-agent policy state
   guard/       loop-hygiene plugins
   cordis/      self-referential toolset: the agent inspects/mounts plugins in its own runtime
   hooks/       Claude Code / Codex hook bridges + shared wire-protocol library

+ 5 - 0
docs/capability-seams.md

@@ -38,6 +38,8 @@ flowchart LR
   pkg_user_interaction["user-interaction"]
   svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"]
   pkg_stdio_agent["stdio-agent"]
+  pkg_mode["mode"]
+  svc_modes["ctx.modes<br/>Session-mode policy state"]
   svc_agents["ctx.agents<br/>Agent registry"]
   svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
   pkg_agent_core["agent-core"]
@@ -82,6 +84,7 @@ flowchart LR
   pkg_llm_deepseek --> svc_llm
   pkg_llm_pi_ai --> svc_llm
   pkg_llm_replay --> svc_llm
+  pkg_mode --> svc_modes
   pkg_session --> svc_sessions
   pkg_session_persistence --> svc_sessionPersistence
   pkg_session_persistence_jsonl --> svc_sessionPersistence
@@ -114,6 +117,7 @@ flowchart LR
   svc_fs --> pkg_tool_fs
   svc_llm --> pkg_agent_loop
   svc_llm --> pkg_compact_basic
+  svc_modes --> pkg_stdio_agent
   svc_sessionPersistence --> pkg_acp
   svc_sessionPersistence --> pkg_agent_loop
   svc_sessions --> pkg_agent
@@ -150,6 +154,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-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
 | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`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.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`stdio-agent`](../packages/ui/stdio-agent) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
 | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |

+ 29 - 0
docs/config-catalog.md

@@ -398,6 +398,35 @@ export interface Config {
 
 Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
 
+## `@deepseek-ai/dsh-mode`
+
+Requires: `tools` · `systemPrompt`
+
+```ts config-catalog
+/**
+ * Plugin config: mode definitions by name. The built-in {@link PLAN_MODE}
+ * definition is merged in unless overridden; {@link DEFAULT_MODE} is rejected
+ * as a key ({@link resolveConfig} throws at load).
+ */
+export interface ModeConfig {
+  /** Mode definitions by name, overriding or extending the built-in `plan`. */
+  modes?: Record<string, ModeDefinition>
+}
+
+/**
+ * One mode's deployment-configured policy: the guidance section the model sees
+ * and the allowlist of tool names that stay visible and executable.
+ */
+export interface ModeDefinition {
+  /** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
+  section: string
+  /** Allowlist of tool NAMES; names may reference not-yet-registered tools (registration is dynamic). */
+  tools: string[]
+}
+```
+
+Source: [`packages/mode/mode/src/index.ts:94`](../packages/mode/mode/src/index.ts)
+
 ## `@deepseek-ai/dsh-repeat-tool-guard`
 
 ```ts config-catalog

+ 14 - 0
docs/cordis-catalog/services.md

@@ -147,6 +147,20 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
 
 Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
 
+## `ctx.modes` — `ModesService`
+
+`ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the pending-intent flush, the boundary narration, and both policy layers (the assemble filter + `mode:policy` section, and the `tools/pre-execute` gate). UIs read mode flips off `session/event`; there is no live mirror.
+
+```ts cordis-catalog
+list(): string[]
+get(agent: Agent): { current: string, pending?: string }
+set(agent: Agent, mode: string): void
+```
+
+Types: [Agent](../core-data-structures/core.md)
+
+Source: [`packages/mode/mode/src/index.ts:179`](../../packages/mode/mode/src/index.ts)
+
 ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
 
 Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).

+ 4 - 4
docs/event-producer-consumer.md

@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`mode`](../packages/mode/mode), [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:476`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
 | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
@@ -24,17 +24,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`mode`](../packages/mode/mode), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
-| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
+| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`mode`](../packages/mode/mode) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:132`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:127`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
 
 Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.

+ 10 - 1
docs/module-graph.md

@@ -61,6 +61,9 @@ flowchart TD
   subgraph group_todo["packages/todo"]
     pkg_tool_todo["tool-todo"]
   end
+  subgraph group_mode["packages/mode"]
+    pkg_mode["mode"]
+  end
   subgraph group_cordis["packages/cordis"]
     pkg_tool_cordis["tool-cordis"]
   end
@@ -173,6 +176,10 @@ flowchart TD
   pkg_tool_todo --> pkg_agent
   pkg_tool_todo --> pkg_session
   pkg_tool_todo --> pkg_tools
+  pkg_mode --> pkg_agent
+  pkg_mode --> pkg_session
+  pkg_mode --> pkg_system_prompt
+  pkg_mode --> pkg_tools
   pkg_tool_cordis --> pkg_tools
   pkg_hooks_codex --> pkg_agent
   pkg_hooks_codex --> pkg_hook_protocol
@@ -236,6 +243,7 @@ flowchart TD
   pkg_stdio_agent --> pkg_agent_core
   pkg_stdio_agent --> pkg_app_boot
   pkg_stdio_agent --> pkg_llm
+  pkg_stdio_agent --> pkg_mode
   pkg_stdio_agent --> pkg_session
   pkg_stdio_agent --> pkg_session_persistence_jsonl
   pkg_stdio_agent --> pkg_tool_ask_user
@@ -284,6 +292,7 @@ flowchart TD
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
 | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`mode`](../packages/mode/mode) | `mode` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`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), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
@@ -298,4 +307,4 @@ flowchart TD
 | [`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-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`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) |
-| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`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), [`user-interaction`](../packages/ui/user-interaction) |
+| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`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), [`user-interaction`](../packages/ui/user-interaction) |

+ 12 - 0
docs/persistence-catalog.md

@@ -107,6 +107,18 @@ A hook command's outcome — log-only, paired with a prior `hook/invoked` (same
 
 Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook-protocol/src/types.ts)
 
+### `mode/*`
+
+#### `mode/set` — log-only
+
+The session mode in force from this point on: log-only, non-surface, whole-value replace — the last `mode/set` in the log wins (see foldMode). A log with none folds to DEFAULT_MODE.
+
+```ts persistence-catalog
+'mode/set': { mode: string }
+```
+
+Source: [`packages/mode/mode/src/index.ts:39`](../packages/mode/mode/src/index.ts)
+
 ### `prompt/*`
 
 #### `prompt/blocked` — log-only

+ 1 - 0
packages/README.md

@@ -18,6 +18,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
 | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
 | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
+| [`mode/`](mode/README.md) | Session-mode policy family: plan mode as logged per-agent state with soft/hard enforcement | Product — stable surface |
 | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
 | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
 | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |

+ 9 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -125,6 +125,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
     ],
   },
+  {
+    key: 'modes',
+    summary: '`ctx.modes`: the session-mode service.',
+    methods: [
+      'list(): string[]',
+      'get(agent: Agent): { current: string, pending?: string }',
+      'set(agent: Agent, mode: string): void',
+    ],
+  },
   {
     key: 'sessionPersistence',
     summary: 'Abstract durable session-persistence service.',

+ 9 - 0
packages/mode/README.md

@@ -0,0 +1,9 @@
+# mode/ — session-mode policy family
+
+Session modes: named, logged, per-agent policy states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable parts are config values (allowlist, section text), not swappable implementations.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section) and the hard layer (`tools/pre-execute` deny-by-default gate) | `ctx.modes` |
+
+The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).

+ 38 - 0
packages/mode/mode/README.md

@@ -0,0 +1,38 @@
+# @deepseek-ai/dsh-mode
+
+Session modes: named, logged, per-agent policy states. **Plan mode** is the first shipped definition — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority through an explicit review.
+
+## The mode state is a session event
+
+`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the mode in force (the last `mode/set`, else `default`). Because the log is the fact channel, resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event` — there is no live mirror.
+
+The `default` mode is the absence of policy: no section, no filtering, no gate. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin.
+
+## Two layers of enforcement
+
+**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header-delta` on the next step. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
+
+**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
+
+## `ctx.modes`
+
+`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent at the next `turn/start`/`step/end` and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
+
+`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
+
+## Config
+
+```yaml
+- id: mode
+  name: '@deepseek-ai/dsh-mode'
+  config:
+    modes:
+      plan:
+        section: |
+          You are in plan mode: ...
+        tools: [read, todo_write, web_search, web_fetch, exit_plan_mode]
+```
+
+Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
+
+RFC: [plan mode](../../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).

+ 40 - 0
packages/mode/mode/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "@deepseek-ai/dsh-mode",
+  "description": "Session modes for the DeepSeek Harness: plan mode as logged per-agent policy state with soft (prompt) and hard (execution) enforcement",
+  "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-session": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 335 - 0
packages/mode/mode/src/index.ts

@@ -0,0 +1,335 @@
+/**
+ * Session modes: named, logged, per-agent policy states, with **plan mode** as
+ * the first shipped definition. A mode names which tools stay visible (the
+ * soft layer, a `system-prompt/assemble` filter plus a guidance section) and
+ * which may run (the hard layer, a deny-by-default `tools/pre-execute` gate);
+ * the mode IN FORCE for an agent is session state, folded from its log
+ * (`mode/set`, last one wins), so resume and fork restore it for free.
+ *
+ * The default mode is the absence of policy: no section, no filtering, no
+ * gate. An agent that never sees a `mode/set` behaves byte-identically to a
+ * deployment that never loads this plugin, so it is safe to compose
+ * unconditionally.
+ *
+ * User flips go through {@link ModesService.set}: every session event is
+ * turn-enclosed and an idle agent has no open turn, so `set()` records a
+ * pending intent and the service flushes it at the next boundary
+ * (`turn/start` / `step/end` — both outside the step's tool-execution window).
+ * A flush that changes what the last logged request header told the model
+ * appends one coalesced `context/message` notice in the same frame.
+ *
+ * RFC: docs/rfc/proposed/feature/2026-07-07-plan-mode.md
+ *
+ * @module @deepseek-ai/dsh-mode
+ */
+
+import { Context, Service } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+
+declare module '@deepseek-ai/dsh-session' {
+  interface SessionEventMap {
+    /**
+     * The session mode in force from this point on: log-only, non-surface,
+     * whole-value replace — the last `mode/set` in the log wins (see
+     * {@link foldMode}). A log with none folds to {@link DEFAULT_MODE}.
+     */
+    'mode/set': { mode: string }
+  }
+}
+
+declare module '@deepseek-ai/dsh-agent' {
+  interface AgentOptions {
+    /**
+     * Initial session mode for this agent. Applied as a pending intent flushed
+     * at the first turn boundary; an explicit option beats the logged baseline
+     * on create AND resume. An unknown name throws at agent creation.
+     */
+    mode?: string
+  }
+}
+
+declare module 'cordis' {
+  interface Context {
+    modes: ModesService
+  }
+}
+
+/**
+ * The mode a log with no `mode/set` folds to: the absence of policy. Reserved —
+ * {@link resolveConfig} rejects it as a definition key, and {@link ModesService.set}
+ * always accepts it as a target (a picker's exit-to-default is a valid write).
+ */
+export const DEFAULT_MODE = 'default'
+
+/** The one shipped mode definition's name. */
+export const PLAN_MODE = 'plan'
+
+/**
+ * The model-facing exit tool's name. The assemble filter shows the tool IFF the
+ * folded mode is {@link PLAN_MODE}, which keeps a default-mode assembly
+ * byte-identical to a deployment without this plugin even though the tool is
+ * always registered.
+ */
+export const EXIT_PLAN_MODE = 'exit_plan_mode'
+
+/**
+ * One mode's deployment-configured policy: the guidance section the model sees
+ * and the allowlist of tool names that stay visible and executable.
+ */
+export interface ModeDefinition {
+  /** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
+  section: string
+  /** Allowlist of tool NAMES; names may reference not-yet-registered tools (registration is dynamic). */
+  tools: string[]
+}
+
+/**
+ * Plugin config: mode definitions by name. The built-in {@link PLAN_MODE}
+ * definition is merged in unless overridden; {@link DEFAULT_MODE} is rejected
+ * as a key ({@link resolveConfig} throws at load).
+ */
+export interface ModeConfig {
+  /** Mode definitions by name, overriding or extending the built-in `plan`. */
+  modes?: Record<string, ModeDefinition>
+}
+
+/** Validated mode definitions: the built-in `plan` merged with (or replaced by) the configured ones. */
+export interface ResolvedModes {
+  /** Definitions by mode name; never contains {@link DEFAULT_MODE}. */
+  definitions: ReadonlyMap<string, ModeDefinition>
+}
+
+const PLAN_SECTION
+  = 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
+  + 'do not attempt to modify anything — mutating tools are not available and calls '
+  + 'to them are denied. When your plan is ready, present it with the exit_plan_mode '
+  + 'tool and wait for the user\'s review. If exit_plan_mode is unavailable or its '
+  + 'review fails, ask the user to switch the session out of plan mode instead of '
+  + 'retrying denied tools.'
+
+const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
+
+/**
+ * Validate the config and merge the built-in `plan` definition (explicit
+ * resolve step — the `dsh-bash` request/spec template). Fail-loud: a
+ * {@link DEFAULT_MODE} key or a malformed definition throws at load.
+ *
+ * @param config Raw plugin config.
+ * @returns The validated definitions, `plan` included unless overridden.
+ */
+export function resolveConfig(config: ModeConfig): ResolvedModes {
+  const definitions = new Map<string, ModeDefinition>()
+  definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS] })
+  for (const [name, definition] of Object.entries(config.modes ?? {})) {
+    if (name === DEFAULT_MODE) {
+      throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
+    }
+    if (typeof definition.section !== 'string') {
+      throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
+    }
+    if (!Array.isArray(definition.tools) || definition.tools.some(tool => typeof tool !== 'string')) {
+      throw new Error(`ModeConfig: mode "${name}" needs a \`tools\` array of tool names`)
+    }
+    definitions.set(name, { section: definition.section, tools: [...definition.tools] })
+  }
+  return { definitions }
+}
+
+/**
+ * The mode in force after the first `end` events: the last `mode/set` wins,
+ * a prefix with none folds to {@link DEFAULT_MODE}. Pure — exported for
+ * reconstructors and tests.
+ *
+ * @param events The session log (or any prefix of it).
+ * @param end Fold `events[0, end)`; defaults to the whole log.
+ * @returns The folded mode name.
+ */
+export function foldMode(events: readonly SessionEvent[], end = events.length): string {
+  let mode = DEFAULT_MODE
+  let index = 0
+  for (const event of events) {
+    if (index >= end) break
+    index++
+    if (event.type === 'mode/set') mode = event.data.mode
+  }
+  return mode
+}
+
+/** The mode the last logged request header shipped under, or `undefined` before the first header. */
+function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
+  let lastHeader = -1
+  let index = 0
+  for (const event of events) {
+    if (event.type === 'request/header' || event.type === 'request/header-delta') lastHeader = index
+    index++
+  }
+  if (lastHeader < 0) return undefined
+  return foldMode(events, lastHeader + 1)
+}
+
+/**
+ * `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
+ * pending-intent flush, the boundary narration, and both policy layers (the
+ * assemble filter + `mode:policy` section, and the `tools/pre-execute` gate).
+ * UIs read mode flips off `session/event`; there is no live mirror.
+ */
+export class ModesService extends Service {
+  static inject = ['tools', 'systemPrompt']
+
+  /** Validated definitions (built-in `plan` merged unless overridden). */
+  readonly resolved: ResolvedModes
+
+  /** The latest user-selected mode per session, awaiting its turn-boundary flush. */
+  private readonly pendingIntents = new WeakMap<Session, string>()
+
+  /** The unknown folded-mode name already narrated per session (once per name). */
+  private readonly droppedNoticed = new WeakMap<Session, string>()
+
+  constructor(ctx: Context, config: ModeConfig = {}) {
+    super(ctx, 'modes')
+    this.resolved = resolveConfig(config)
+
+    ctx.on('session/event', (session, event) => {
+      if (event.type !== 'turn/start' && event.type !== 'step/end') return
+      try {
+        this.onBoundary(session, event.type === 'turn/start')
+      } catch (error) {
+        // Contained (a policy plugin must never kill the session feed): the only
+        // throw path in onBoundary is session.append rejecting mid-teardown.
+        ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
+      }
+    })
+
+    ctx.on('agent/created', (agent) => {
+      const seed = agent.options.mode
+      if (seed === undefined) return
+      this.set(agent, seed)
+    })
+
+    ctx.systemPrompt.section({
+      name: 'mode:policy',
+      order: 50,
+      text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
+    })
+
+    ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
+      const result = await next()
+      const agent = context.agent
+      if (agent === undefined) return result
+      const active = this.activeDefinition(agent.session)
+      if (active === undefined) {
+        result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
+        return result
+      }
+      const allowed = new Set(active.definition.tools)
+      result.tools = result.tools.filter(tool =>
+        allowed.has(tool.name) && (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
+      return result
+    })
+
+    ctx.on('tools/pre-execute', (exec, next): Promise<PreToolDecision> => {
+      if (exec.agent === undefined) return next()
+      const active = this.activeDefinition(exec.agent.session)
+      if (active === undefined) return next()
+      if (active.definition.tools.includes(exec.name)) return next()
+      const reason = active.name === PLAN_MODE
+        ? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`
+        : `tool "${exec.name}" is not available in "${active.name}" mode`
+      return Promise.resolve({ kind: 'deny', reason })
+    })
+  }
+
+  /**
+   * The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the
+   * configured definitions — the list a mode picker advertises.
+   *
+   * @returns Mode names, `default` first.
+   */
+  list(): string[] {
+    return [DEFAULT_MODE, ...this.resolved.definitions.keys()]
+  }
+
+  /**
+   * The agent's mode state: the folded mode in force (a folded name the config
+   * no longer defines reads as {@link DEFAULT_MODE}) plus the pending
+   * user-selected intent awaiting its boundary flush, when one exists.
+   *
+   * @param agent The agent to read.
+   * @returns The current (effective) mode and the pending intent, if any.
+   */
+  get(agent: Agent): { current: string; pending?: string } {
+    const current = this.activeDefinition(agent.session)?.name ?? DEFAULT_MODE
+    const pending = this.pendingIntents.get(agent.session)
+    return pending === undefined ? { current } : { current, pending }
+  }
+
+  /**
+   * Select the agent's mode. Validates the name against {@link list} (loud on
+   * unknown; `default` is always a valid target), drops a no-op (target equals
+   * the pending intent, else the current fold), and otherwise records a
+   * pending intent flushed as a `mode/set` at the next turn boundary.
+   *
+   * @param agent The agent to switch.
+   * @param mode The target mode name.
+   */
+  set(agent: Agent, mode: string): void {
+    if (mode !== DEFAULT_MODE && !this.resolved.definitions.has(mode)) {
+      throw new Error(`unknown mode "${mode}" — available modes: ${this.list().join(', ')}`)
+    }
+    const session = agent.session
+    const target = this.pendingIntents.get(session) ?? this.get(agent).current
+    if (mode === target) return
+    this.pendingIntents.set(session, mode)
+  }
+
+  /** The folded mode's definition, or `undefined` for the default mode and for a folded name the config no longer defines. */
+  private activeDefinition(session: Session): { name: string; definition: ModeDefinition } | undefined {
+    const name = foldMode(session.events)
+    if (name === DEFAULT_MODE) return undefined
+    const definition = this.resolved.definitions.get(name)
+    if (definition === undefined) return undefined
+    return { name, definition }
+  }
+
+  /**
+   * One turn-boundary pass: narrate a folded mode the config dropped (once per
+   * name, turn starts only), then flush the pending intent — append the
+   * `mode/set` (skipped when the fold already matches: a net-zero flip
+   * sequence) and the one coalesced notice when the flushed mode differs from
+   * what the last logged request header told the model.
+   */
+  private onBoundary(session: Session, turnStart: boolean): void {
+    if (turnStart) this.noticeDroppedDefinition(session)
+    const target = this.pendingIntents.get(session)
+    if (target === undefined) return
+    this.pendingIntents.delete(session)
+    if (target === foldMode(session.events)) return
+    session.append('mode/set', { mode: target })
+    const told = modeAtLastHeader(session.events)
+    if (told === undefined || told === target) return
+    const text = target === DEFAULT_MODE
+      ? 'The user switched this session back to the default mode.'
+      : `The user switched this session to ${target} mode.`
+    session.append('context/message', {
+      content: [{ type: 'text', text }],
+      source: { kind: 'plugin', plugin: 'mode' },
+    }, { surfaceOp: 'append' })
+  }
+
+  /** Narrate a folded mode name the current config no longer defines — the session reads as default plus this one notice. */
+  private noticeDroppedDefinition(session: Session): void {
+    const name = foldMode(session.events)
+    if (name === DEFAULT_MODE || this.resolved.definitions.has(name)) return
+    if (this.droppedNoticed.get(session) === name) return
+    this.droppedNoticed.set(session, name)
+    session.append('context/message', {
+      content: [{ type: 'text', text: `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.` }],
+      source: { kind: 'plugin', plugin: 'mode' },
+    }, { surfaceOp: 'append' })
+  }
+}
+
+export default ModesService

+ 117 - 0
packages/mode/mode/tests/integration.spec.ts

@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import ModesService, { PLAN_MODE, foldMode } from '@deepseek-ai/dsh-mode'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+
+/**
+ * Full-loop integration: a scripted mock model drives the REAL mode plugin
+ * through the agent loop — the pending-intent flush at the turn boundary, the
+ * assembly the soft layer filters, the `request/header`/`request/header-delta`
+ * trail every transition leaves, and the hard gate's deny. Only the model is
+ * mocked; the loop, the session log, and the plugin are real.
+ */
+async function harness(adapter: MockAdapter): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(ModesService)
+  ctx.llm.registerAdapter(['mock'], adapter)
+  for (const name of ['read', 'write']) {
+    ctx.tools.register(defineTool({
+      name,
+      description: `test tool ${name}`,
+      parameters: {},
+      execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
+    }))
+  }
+  return ctx
+}
+
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+function findEvent<T extends SessionEvent['type']>(
+  log: readonly SessionEvent[],
+  type: T,
+  position: 'first' | 'last' = 'first',
+): Extract<SessionEvent, { type: T }> {
+  const found = position === 'first'
+    ? log.find(event => event.type === type)
+    : log.findLast(event => event.type === type)
+  if (!found) throw new Error(`no ${type} event in the session log`)
+  return found as Extract<SessionEvent, { type: T }>
+}
+
+describe('plan mode through the agent loop', () => {
+  it('seeds plan mode from AgentOptions: the FIRST header is already plan-shaped and the gate denies a write', async () => {
+    const adapter = new MockAdapter([
+      toolCallResponse('call-1', 'write', {}, 'Trying to write anyway.'),
+      textResponse('Back to planning.'),
+    ])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('it-plan-seed'), { model: 'mock', mode: PLAN_MODE })
+
+    agent.send([{ type: 'text', text: 'explore the repo' }])
+    await waitForIdle(ctx, agent)
+
+    const log = agent.session.events
+    const modeSet = findEvent(log, 'mode/set')
+    const header = findEvent(log, 'request/header')
+    expect(modeSet.seq).toBeLessThan(header.seq)
+    expect(header.data.reason).toBe('initial')
+    expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['read'])
+    expect(header.data.header.system).toContain('plan mode')
+
+    const result = findEvent(log, 'tool/result')
+    expect(result.data.isError).toBe(true)
+    expect(foldMode(log)).toBe(PLAN_MODE)
+    expect(log.some(event => event.type === 'context/message')).toBe(false)
+  })
+
+  it('a user flip between turns lands at the boundary: one notice and the widening header delta', async () => {
+    const adapter = new MockAdapter([
+      textResponse('First turn, default mode.'),
+      textResponse('Second turn, plan mode.'),
+    ])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('it-plan-flip'), { model: 'mock' })
+
+    agent.send([{ type: 'text', text: 'hello' }])
+    await waitForIdle(ctx, agent)
+    expect(foldMode(agent.session.events)).toBe('default')
+
+    ctx.modes.set(agent, PLAN_MODE)
+    agent.send([{ type: 'text', text: 'now plan' }])
+    await waitForIdle(ctx, agent)
+
+    const log = agent.session.events
+    expect(foldMode(log)).toBe(PLAN_MODE)
+    const notices = log.filter(event => event.type === 'context/message')
+    expect(notices).toHaveLength(1)
+    expect(findEvent(log, 'context/message').data.content).toEqual([
+      { type: 'text', text: 'The user switched this session to plan mode.' },
+    ])
+    const delta = findEvent(log, 'request/header-delta')
+    expect(delta.data.tools).toBeDefined()
+    expect(delta.data.system).toBeDefined()
+  })
+})

+ 384 - 0
packages/mode/mode/tests/mode.spec.ts

@@ -0,0 +1,384 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
+import type { ModeConfig } from '../src/index.ts'
+
+/**
+ * Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
+ * `ToolRegistry` services, with fake Agents carrying real `Session`s (the
+ * tool-todo test shape). Turn boundaries are simulated by appending the real
+ * boundary events and emitting `session/event` by hand — exactly the feed the
+ * store wires in production.
+ */
+
+function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agent & { session: Session } {
+  const session = new Session(SessionId(id))
+  return { id: AgentId(id), session, options } as unknown as Agent & { session: Session }
+}
+
+async function setup(config?: ModeConfig): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(ModesService, config)
+  return ctx
+}
+
+/** Append a boundary event and hand-emit the `session/event` feed the store would. */
+function boundary(ctx: Context, session: Session, type: 'turn/start' | 'step/end'): void {
+  const event = type === 'turn/start'
+    ? session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    : session.append('step/end', { turn: 1, step: 1 })
+  ctx.emit('session/event', session, event as SessionEvent)
+}
+
+/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
+function header(session: Session): void {
+  session.append('request/header', { header: { config: { model: 'test-model' } }, reason: 'initial' })
+}
+
+function noticeTexts(session: Session): string[] {
+  return session.events
+    .filter(event => event.type === 'context/message')
+    .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
+}
+
+function registerNamedTools(ctx: Context, names: string[]): void {
+  for (const name of names) {
+    ctx.tools.register(defineTool({
+      name,
+      description: `test tool ${name}`,
+      parameters: {},
+      execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
+    }))
+  }
+}
+
+let callCounter = 0
+function execute(ctx: Context, name: string, agent?: Agent) {
+  return ctx.tools.execute({
+    callId: CallId(`call-${++callCounter}`),
+    name,
+    arguments: {},
+    ...agent ? { agent } : {},
+  })
+}
+
+describe('resolveConfig', () => {
+  it('merges the built-in plan definition with the read-only allowlist', () => {
+    const resolved = resolveConfig({})
+    const plan = resolved.definitions.get(PLAN_MODE)
+    expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE])
+    expect(plan?.section).toContain('plan mode')
+  })
+
+  it('lets config override plan and add further modes', () => {
+    const resolved = resolveConfig({ modes: {
+      plan: { section: 'custom plan', tools: ['read'] },
+      review: { section: 'review', tools: ['read', 'write'] },
+    } })
+    expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan', tools: ['read'] })
+    expect(resolved.definitions.get('review')).toEqual({ section: 'review', tools: ['read', 'write'] })
+  })
+
+  it('rejects the reserved default key loudly', () => {
+    expect(() => resolveConfig({ modes: { default: { section: '', tools: [] } } }))
+      .toThrow('"default" is reserved')
+  })
+
+  it('rejects a malformed definition loudly', () => {
+    expect(() => resolveConfig({ modes: { bad: { section: 5, tools: [] } as unknown as { section: string; tools: string[] } } }))
+      .toThrow('needs a string `section`')
+    expect(() => resolveConfig({ modes: { bad: { section: '', tools: 'read' } as unknown as { section: string; tools: string[] } } }))
+      .toThrow('needs a `tools` array')
+    expect(() => resolveConfig({ modes: { bad: { section: '', tools: [7] } as unknown as { section: string; tools: string[] } } }))
+      .toThrow('needs a `tools` array')
+  })
+})
+
+describe('foldMode', () => {
+  it('folds an empty log to the default mode and takes the last mode/set otherwise', () => {
+    const session = new Session(SessionId('fold'))
+    expect(foldMode(session.events)).toBe(DEFAULT_MODE)
+    session.append('mode/set', { mode: 'plan' })
+    session.append('mode/set', { mode: 'default' })
+    session.append('mode/set', { mode: 'plan' })
+    expect(foldMode(session.events)).toBe('plan')
+  })
+
+  it('folds a prefix when `end` is given', () => {
+    const session = new Session(SessionId('fold-prefix'))
+    session.append('mode/set', { mode: 'plan' })
+    session.append('mode/set', { mode: 'default' })
+    expect(foldMode(session.events, 1)).toBe('plan')
+    expect(foldMode(session.events, 0)).toBe(DEFAULT_MODE)
+  })
+})
+
+describe('ctx.modes: list/get/set', () => {
+  it('lists default first, then the configured definitions', async () => {
+    const ctx = await setup({ modes: { review: { section: 's', tools: [] } } })
+    expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
+  })
+
+  it('reads the folded mode, mapping a dropped definition to default', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
+    agent.session.append('mode/set', { mode: 'retired' })
+    expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
+  })
+
+  it('rejects an unknown mode name loudly, naming the vocabulary', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    expect(() => { ctx.modes.set(agent, 'nope') }).toThrow('unknown mode "nope" — available modes: default, plan')
+  })
+
+  it('accepts default as a target (exit-to-default is a valid write)', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    ctx.modes.set(agent, DEFAULT_MODE)
+    expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
+  })
+
+  it('drops a no-op set (target equals pending, else the current fold)', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, DEFAULT_MODE)
+    expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
+    ctx.modes.set(agent, PLAN_MODE)
+    ctx.modes.set(agent, PLAN_MODE)
+    expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
+  })
+
+  it('seeds the initial mode from AgentOptions.mode on agent/created', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession('seeded', { mode: PLAN_MODE })
+    ctx.emit('agent/created', agent)
+    expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
+    const bare = agentWithSession('unseeded')
+    ctx.emit('agent/created', bare)
+    expect(ctx.modes.get(bare)).toEqual({ current: DEFAULT_MODE })
+  })
+})
+
+describe('the boundary flush', () => {
+  it('flushes the pending intent as a mode/set at turn/start', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    boundary(ctx, agent.session, 'turn/start')
+    expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+    expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
+  })
+
+  it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    boundary(ctx, agent.session, 'step/end')
+    expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+  })
+
+  it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    ctx.modes.set(agent, DEFAULT_MODE)
+    boundary(ctx, agent.session, 'turn/start')
+    expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
+    expect(noticeTexts(agent.session)).toEqual([])
+  })
+
+  it('narrates nothing before the first request header (the section is the state statement)', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    boundary(ctx, agent.session, 'turn/start')
+    expect(noticeTexts(agent.session)).toEqual([])
+  })
+
+  it('narrates once when the flushed mode differs from what the last header told the model', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    header(agent.session)
+    ctx.modes.set(agent, PLAN_MODE)
+    boundary(ctx, agent.session, 'turn/start')
+    expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
+    boundary(ctx, agent.session, 'step/end')
+    expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
+  })
+
+  it('narrates a switch back to the default mode with the default wording', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    header(agent.session)
+    ctx.modes.set(agent, DEFAULT_MODE)
+    boundary(ctx, agent.session, 'step/end')
+    expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
+  })
+
+  it('stays silent when the header already reflects the flushed mode', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    header(agent.session)
+    agent.session.append('mode/set', { mode: DEFAULT_MODE })
+    ctx.modes.set(agent, PLAN_MODE)
+    boundary(ctx, agent.session, 'step/end')
+    expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
+    expect(noticeTexts(agent.session)).toEqual([])
+  })
+
+  it('narrates a folded mode the config no longer defines, once, at turn starts', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: 'retired' })
+    boundary(ctx, agent.session, 'turn/start')
+    boundary(ctx, agent.session, 'turn/start')
+    expect(noticeTexts(agent.session)).toEqual([
+      'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
+    ])
+    boundary(ctx, agent.session, 'step/end')
+    expect(noticeTexts(agent.session)).toHaveLength(1)
+  })
+
+  it('ignores non-boundary session events on the feed', async () => {
+    const ctx = await setup()
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    const event = agent.session.append('mode/set', { mode: DEFAULT_MODE })
+    ctx.emit('session/event', agent.session, event as SessionEvent)
+    expect(ctx.modes.get(agent).pending).toBe(PLAN_MODE)
+  })
+
+  it('contains an append failure instead of killing the session feed', async () => {
+    const ctx = await setup()
+    const warn = vi.fn()
+    ctx.logger.warn = warn as never
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    const original = agent.session.append.bind(agent.session)
+    agent.session.append = (() => { throw new Error('backend gone') })
+    expect(() => { boundary({ emit: ctx.emit.bind(ctx) } as never as Context, agent.session, 'step/end') }).toThrow('backend gone')
+    agent.session.append = original
+    const event = agent.session.append('step/end', { turn: 1, step: 2 })
+    agent.session.append = (() => { throw new Error('backend gone') })
+    ctx.emit('session/event', agent.session, event as SessionEvent)
+    expect(warn).toHaveBeenCalledOnce()
+  })
+})
+
+describe('the soft layer', () => {
+  it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+    const agent = agentWithSession()
+    const assembly = await ctx.systemPrompt.assemble({ agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
+    expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
+  })
+
+  it('leaves an agent-less assembly untouched', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['read', EXIT_PLAN_MODE])
+    const assembly = await ctx.systemPrompt.assemble()
+    expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
+    expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
+  })
+
+  it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['read', 'write', 'todo_write', EXIT_PLAN_MODE])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    const assembly = await ctx.systemPrompt.assemble({ agent })
+    expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write'])
+    expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode')
+  })
+
+  it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
+    const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
+    registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: 'review' })
+    const assembly = await ctx.systemPrompt.assemble({ agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['read'])
+    expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
+  })
+
+  it('treats a dropped folded definition as the default mode', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: 'retired' })
+    const assembly = await ctx.systemPrompt.assemble({ agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
+  })
+})
+
+describe('the hard layer', () => {
+  it('passes agent-less and default-mode executions through', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['write'])
+    const agentless = await execute(ctx, 'write')
+    expect(agentless.isError).toBe(false)
+    const agent = agentWithSession()
+    const defaulted = await execute(ctx, 'write', agent)
+    expect(defaulted.isError).toBe(false)
+  })
+
+  it('passes allowlisted calls and denies the rest with the plan-mode reason', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['read', 'write'])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: PLAN_MODE })
+    const allowed = await execute(ctx, 'read', agent)
+    expect(allowed.isError).toBe(false)
+    const denied = await execute(ctx, 'write', agent)
+    expect(denied.isError).toBe(true)
+    expect(denied.content).toEqual([{
+      type: 'text',
+      text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
+    }])
+  })
+
+  it('denies with the generic reason in a custom mode', async () => {
+    const ctx = await setup({ modes: { review: { section: 's', tools: ['read'] } } })
+    registerNamedTools(ctx, ['write'])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: 'review' })
+    const denied = await execute(ctx, 'write', agent)
+    expect(denied.isError).toBe(true)
+    expect(denied.content).toEqual([{ type: 'text', text: 'Error: tool "write" is not available in "review" mode' }])
+  })
+
+  it('judges by the logged mode only — a pending intent does not gate', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['write'])
+    const agent = agentWithSession()
+    ctx.modes.set(agent, PLAN_MODE)
+    const result = await execute(ctx, 'write', agent)
+    expect(result.isError).toBe(false)
+  })
+
+  it('treats a dropped folded definition as the default mode (no gate)', async () => {
+    const ctx = await setup()
+    registerNamedTools(ctx, ['write'])
+    const agent = agentWithSession()
+    agent.session.append('mode/set', { mode: 'retired' })
+    const result = await execute(ctx, 'write', agent)
+    expect(result.isError).toBe(false)
+  })
+})

+ 30 - 0
packages/mode/mode/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/system-prompt"
+    }
+  ]
+}

+ 2 - 0
packages/ui/stdio-agent/package.json

@@ -37,6 +37,7 @@
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-agent-core": "^0.0.1",
+    "@deepseek-ai/dsh-mode": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -53,6 +54,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-agent-core": "workspace:^",
+    "@deepseek-ai/dsh-mode": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 28 - 0
packages/ui/stdio-agent/src/stdio-chat.ts

@@ -28,6 +28,9 @@ import {
   type AskUserQuestionOption,
   type AskUserQuestionRequest,
 } from '@deepseek-ai/dsh-user-interaction'
+// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
+// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
+import type {} from '@deepseek-ai/dsh-mode'
 
 export const name = 'ui-stdio'
 export const inject = ['agents', 'userInteraction']
@@ -357,6 +360,31 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
         ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
         return
       }
+      if (text === '/mode' || text.startsWith('/mode ')) {
+        // A command line, never sent to the model: print or switch the session
+        // mode. The switch is a pending intent the mode service flushes at the
+        // next turn boundary (dsh-mode's turn-enclosure contract).
+        const modes = ctx.get('modes')
+        if (modes === undefined) {
+          output.write('session modes are not composed in this deployment\n> ')
+          return
+        }
+        const target = text.slice('/mode'.length).trim()
+        if (target === '') {
+          const { current, pending } = modes.get(agent)
+          const pendingNote = pending === undefined ? '' : ` (pending: ${pending})`
+          output.write(`mode: ${current}${pendingNote} — available: ${modes.list().join(', ')}\n> `)
+          return
+        }
+        try {
+          modes.set(agent, target)
+          output.write(`mode → ${target} (applies from the next turn)\n> `)
+        } catch (error) {
+          // ModesService.set throws only Error (its unknown-name validation).
+          output.write(`${(error as Error).message}\n> `)
+        }
+        return
+      }
       submittedWork = true
       if (agent.status === 'running') {
         agent.steer([{ type: 'text', text }])

+ 68 - 0
packages/ui/stdio-agent/tests/stdio-chat.spec.ts

@@ -5,6 +5,10 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { Session as RealSession, SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import ModesService, { PLAN_MODE } from '@deepseek-ai/dsh-mode'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
 
@@ -803,3 +807,67 @@ describe('createStdioChat disposal (HMR safety)', () => {
     expect(exit).not.toHaveBeenCalled()
   })
 })
+
+describe('createStdioChat /mode command', () => {
+  /** An agent fake carrying a REAL session, so `ctx.modes` folds a genuine log. */
+  function makeModeAgent(id: string): Agent & { sent: ContentBlock[][] } {
+    const sent: ContentBlock[][] = []
+    return {
+      id: id as Agent['id'],
+      status: 'idle',
+      options: {},
+      sent,
+      session: new RealSession(SessionId(`${id}-session`)),
+      send: (content: ContentBlock[]) => void sent.push(content),
+      steer: () => {},
+    } as never
+  }
+
+  async function setupWithModes() {
+    const bundle = await setup()
+    await bundle.ctx.plugin(SystemPrompt)
+    await bundle.ctx.plugin(ToolRegistry)
+    await bundle.ctx.plugin(ModesService)
+    const agent = makeModeAgent('main')
+    bundle.ctx.agents.register(agent)
+    return { ...bundle, agent }
+  }
+
+  it('reports when session modes are not composed', async () => {
+    const { ctx, input, out } = await setup()
+    const agent = makeAgent('main', 'idle')
+    ctx.agents.register(agent)
+    input.feed('/mode')
+    await new Promise(r => setImmediate(r))
+    expect(out.text()).toContain('session modes are not composed in this deployment')
+    expect(agent.sent).toEqual([])
+  })
+
+  it('prints the current and available modes, never sending the line to the model', async () => {
+    const { input, out, agent } = await setupWithModes()
+    input.feed('/mode')
+    await new Promise(r => setImmediate(r))
+    expect(out.text()).toContain('mode: default — available: default, plan')
+    expect(agent.sent).toEqual([])
+  })
+
+  it('switches the mode as a pending intent and echoes the banner', async () => {
+    const { ctx, input, out, agent } = await setupWithModes()
+    input.feed('/mode plan')
+    await new Promise(r => setImmediate(r))
+    expect(out.text()).toContain('mode → plan (applies from the next turn)')
+    expect(ctx.modes.get(agent)).toEqual({ current: 'default', pending: PLAN_MODE })
+    input.feed('/mode')
+    await new Promise(r => setImmediate(r))
+    expect(out.text()).toContain('mode: default (pending: plan) — available: default, plan')
+    expect(agent.sent).toEqual([])
+  })
+
+  it('prints the validation error for an unknown mode name', async () => {
+    const { ctx, input, out, agent } = await setupWithModes()
+    input.feed('/mode nope')
+    await new Promise(r => setImmediate(r))
+    expect(out.text()).toContain('unknown mode "nope" — available modes: default, plan')
+    expect(ctx.modes.get(agent)).toEqual({ current: 'default' })
+  })
+})

+ 3 - 0
packages/ui/stdio-agent/tsconfig.json

@@ -35,6 +35,9 @@
     {
       "path": "../user-interaction"
     },
+    {
+      "path": "../../mode/mode"
+    },
     {
       "path": "../tool-ask-user"
     },

+ 27 - 0
pnpm-lock.yaml

@@ -605,6 +605,30 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/mode/mode:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/session-persistence/session-persistence:
     devDependencies:
       '@deepseek-ai/dsh-session':
@@ -1081,6 +1105,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-mode':
+        specifier: workspace:^
+        version: link:../../mode/mode
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session

+ 9 - 0
scripts/gen-doc-graphs.ts

@@ -75,6 +75,7 @@ const GROUP_ORDER = [
   'subagent',
   'web',
   'todo',
+  'mode',
   'cordis',
   'hooks',
   'session-persistence',
@@ -134,6 +135,14 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['tool-ask-user', 'stdio-agent', '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: 'modes',
+    pkg: 'mode',
+    title: 'Session-mode policy state',
+    mode: 'core',
+    consumers: ['stdio-agent'],
+    note: 'Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate.',
+  },
   {
     key: 'agents',
     pkg: 'agent',

+ 1 - 0
scripts/gen-module-graph.ts

@@ -47,6 +47,7 @@ const GROUP_ORDER = [
   'web',
   'timeout',
   'todo',
+  'mode',
   'cordis',
   'hooks',
   'session-persistence',

+ 1 - 0
tsconfig.base.json

@@ -47,6 +47,7 @@
         "./packages/fs/*/src",
         "./packages/compact/*/src",
         "./packages/guard/*/src",
+        "./packages/mode/*/src",
         "./packages/subagent/*/src",
         "./packages/web/*/src",
         "./packages/timeout/*/src",

+ 1 - 0
tsconfig.build.json

@@ -59,6 +59,7 @@
     { "path": "./packages/subagent/subagent-fork" },
     { "path": "./packages/subagent/subagent-acp" },
     { "path": "./packages/todo/tool-todo" },
+    { "path": "./packages/mode/mode" },
     { "path": "./packages/guard/repeat-tool-guard" },
     { "path": "./packages/cordis/tool-cordis" },
     { "path": "./packages/hooks/hook-protocol" },

+ 1 - 0
tsconfig.json

@@ -70,6 +70,7 @@
     { "path": "./packages/subagent/subagent-fork" },
     { "path": "./packages/subagent/subagent-acp" },
     { "path": "./packages/todo/tool-todo" },
+    { "path": "./packages/mode/mode" },
     { "path": "./packages/guard/repeat-tool-guard" },
     { "path": "./packages/cordis/tool-cordis" },
     { "path": "./packages/hooks/hook-protocol" },