docs: reconcile the session-modes RFC with the in-flight neighborhood

Re-audited against origin: master unmoved; approval seam still on
feat/sandbox-support; three relevant branches appeared since drafting.

- env-state-visibility RFC (fold-from-log + boundary application for env
  facts): adopt its boundary-narration principle for user-driven mode flips
  (one coalesced notice when the flushed mode differs from the last request
  header's fold; net-zero silent; tool exits self-narrate via result), and
  resolve the one real collision — its config-phase sketch routes ACP
  session/set_mode to env facts while this RFC claims the picker. Proposed
  division: picker-to-modes / knobs-to-config-options; a mode definition may
  later bundle env facts; second lander amends. Also note the source-field
  contrast (env drift has no log-adjacent cause; every mode/set does).
- sandbox-escalation: first live approval composition — named as the
  precedent our recorded scenarios follow.
- ask-user-question / user-interaction seam: the stdio approval answerer
  rides its one-prompt-owns-stdin queue where mounted.
- Config: pin the allowlist as the degenerate form of a per-tool
  allow|deny|ask map so execution-phase ask policies arrive without a
  config migration (deferred on approval's allow_always).
- Dedup the ACP mapping (one home: Protocol and UI surfaces).
This commit is contained in:
kingwl
2026-07-07 15:28:23 +08:00
parent db9217a481
commit 1c2d51c9ca

View File

@@ -28,7 +28,7 @@ The default mode is the absence of policy: no section, no filtering, no gate. An
### Mode changes and turn enclosure ### Mode changes and turn enclosure
Two writers flip the mode. A **tool** (`exit_plan_mode`) appends `mode/set` from inside its own execution — already turn-enclosed, the `todo/write` path. A **user** flips it through `ctx.modes.set(agent, mode)` (a stdio command, ACP `session/set_mode`), and that path cannot append immediately: [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md), and an idle agent has no open turn. The service therefore records a pending intent and flushes it as the first append after the next `turn/start`. Sequencing makes this correct for the request the turn sends: the loop assembles the prompt after the turn opens and before each step, so a flush at `turn/start` is folded by step 1's assembly, while a mid-turn flip lands at the next boundary and takes effect on the following step — the same "applies to subsequent requests" semantics every surveyed product ships. The cost is honest and bounded: a pending intent set while idle is lost if the process dies before the next turn (the UI that set it still holds it and re-applies); promoting user flips to a durable idle-time fact would need a generalized idle-record primitive, which stays out of scope until the loss proves real. Two writers flip the mode. A **tool** (`exit_plan_mode`) appends `mode/set` from inside its own execution — already turn-enclosed, the `todo/write` path. A **user** flips it through `ctx.modes.set(agent, mode)` (a stdio command, ACP `session/set_mode`), and that path cannot append immediately: [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md), and an idle agent has no open turn. The service therefore records a pending intent and flushes it as the first append after the next `turn/start`. Sequencing makes this correct for the request the turn sends: the loop assembles the prompt after the turn opens and before each step, so a flush at `turn/start` is folded by step 1's assembly, while a mid-turn flip lands at the next boundary and takes effect on the following step — the same "applies to subsequent requests" semantics every surveyed product ships. A user flip is also **narrated**: when the flushed mode differs from the fold at the last `request/header`, the service appends one coalesced notice in the same frame ("The user switched this session to plan mode."), so a net-zero flip sequence narrates nothing, a tool-driven exit narrates through its own tool result instead, and a mode set before the first turn narrates nothing (the section is the state statement) — the boundary-narration principle of the in-flight env-state proposal (`docs/rfc/proposed/feature/2026-07-06-env-state-visibility.md`): a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has. The cost is honest and bounded: a pending intent set while idle is lost if the process dies before the next turn (the UI that set it still holds it and re-applies); promoting user flips to a durable idle-time fact would need a generalized idle-record primitive, which stays out of scope until the loss proves real.
### The plan artifact and the exit tool ### The plan artifact and the exit tool
@@ -42,7 +42,7 @@ Mode definitions are validated plugin Config — per repo convention (changeable
### Protocol and UI surfaces ### Protocol and UI surfaces
The stdio app gains a mode toggle command, a banner line, and a readline answerer on the approval waterfall, so the exit approval prompts right in the terminal (the approval seam's one-terminal-answerer-per-deployment convention). The ACP bridge maps the existing protocol surface: `available_modes`/`current_mode_id` from `ctx.modes.list()`/`get()`, `session/set_mode` → `ctx.modes.set()`, and a `current_mode_update` notification off each logged `mode/set`. The exit tool's approval needs no new ACP work at all — it rides the approval seam's answerer. The stdio app gains a mode toggle command, a banner line, and a readline answerer on the approval waterfall, so the exit approval prompts right in the terminal (riding the in-flight user-interaction stdio provider's one-prompt-owns-stdin queue where that seam is mounted — a yes/no confirm is a degenerate single-select — and raw readline otherwise). On ACP, the mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes: they belong to `session/set_config_option`, and the in-flight env-state proposal's config-phase sketch, which currently routes `set_mode` to env facts, is the ONE overlap between the two proposals — the division proposed here is picker-to-modes / knobs-to-config-options, a mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode, and whichever proposal lands second amends its wiring to match. The exit tool's approval needs no new ACP work at all — it rides the approval seam's answerer.
## Detailed design ## Detailed design
@@ -54,7 +54,7 @@ The stdio app gains a mode toggle command, a banner line, and a readline answere
DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable
``` ```
The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability RFC](../../implemented/architecture/2026-07-05-reconstructable-requests.md) made for header deltas. Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded<B>`). The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability RFC](../../implemented/architecture/2026-07-05-reconstructable-requests.md) made for header deltas (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded<B>`).
### Config and the resolve step ### Config and the resolve step
@@ -66,9 +66,11 @@ resolveConfig(config): ResolvedModes // explicit reso
// not-yet-registered tools (registration is dynamic) // not-yet-registered tools (registration is dynamic)
``` ```
The allowlist is deliberately the degenerate form of a future per-tool decision map (`allow | deny | ask`): execution-phase ask policies (an every-write-asks "guarded" mode) stay deferred until the approval seam grows durable grants (`allow_always` — its own open question), and the config shape must not need a migration when they arrive.
### The fold, the service, and the flush ### The fold, the service, and the flush
`foldMode(events)` is pure (exported for reconstructors and tests); the service tracks it per session with a lazy cursor in a `WeakMap<Session, { cursor, mode }>` — O(new events) per read, never invalidated, because the log is append-only and `mode/set` is not a surface node (compaction cannot rewrite it). `ctx.modes` (a cordis Service, key `modes`) exposes `list()` — the synthetic `default` entry plus the configured definitions, for pickers — `get(agent): { current, pending? }`, and `set(agent, mode)`, which validates the name against config, drops a no-op (target equals pending ?? current), and otherwise records the intent in a `WeakMap<Session, string>`. A contained `session/event` listener ([defensive patterns](../../../defensive-patterns.md): a policy plugin must not kill the feed) flushes the pending intent as a `mode/set` append on the next `turn/start` or `step/end` — both sit outside the step's tool-execution window, so the executions of a step always run under the mode its assembly folded. Seeding rides `agent/created`: a declaration-merged `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows — while a fork child needs no mechanism at all (the parent's `mode/set` is inside the seeded prefix). `foldMode(events)` is pure (exported for reconstructors and tests); the service tracks it per session with a lazy cursor in a `WeakMap<Session, { cursor, mode }>` — O(new events) per read, never invalidated, because the log is append-only and `mode/set` is not a surface node (compaction cannot rewrite it). `ctx.modes` (a cordis Service, key `modes`) exposes `list()` — the synthetic `default` entry plus the configured definitions, for pickers — `get(agent): { current, pending? }`, and `set(agent, mode)`, which validates the name against config, drops a no-op (target equals pending ?? current), and otherwise records the intent in a `WeakMap<Session, string>`. A contained `session/event` listener ([defensive patterns](../../../defensive-patterns.md): a policy plugin must not kill the feed) flushes the pending intent as a `mode/set` append on the next `turn/start` or `step/end` — both sit outside the step's tool-execution window, so the executions of a step always run under the mode its assembly folded — and, when the flushed mode differs from the fold at the last `request/header`, appends the one coalesced `context/message` notice in the same frame. Seeding rides `agent/created`: a declaration-merged `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows — while a fork child needs no mechanism at all (the parent's `mode/set` is inside the seeded prefix).
### The soft layer: a computed section and a post-`next()` filter ### The soft layer: a computed section and a post-`next()` filter
@@ -94,7 +96,7 @@ The gate folds the LOGGED mode only, never the pending intent — enforcement ju
### Dependencies and surfaces ### Dependencies and surfaces
`dsh-mode` peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, `dsh-system-prompt` (manifest shape mirrors `dsh-tool-todo`), injects `['tools', 'systemPrompt']`, and depends on neither the approval package nor any UI. The stdio app adds a `/mode [name]` line-handler branch (print or switch + banner, never sent to the model) and the readline answerer for its own agent. The ACP bridge consumes `ctx.modes` opportunistically (`ctx.get`, the `tool-bash` pattern): `session/new`/`session/load` responses include `{ currentModeId: pending ?? current, availableModes: list() }` when the service is mounted, `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. `dsh-mode` peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, `dsh-system-prompt` (manifest shape mirrors `dsh-tool-todo`), injects `['tools', 'systemPrompt']`, and depends on neither the approval package nor any UI. The stdio app adds a `/mode [name]` line-handler branch (print or switch + banner, never sent to the model) and the readline answerer for its own agent. The ACP wire mapping is pinned in Protocol and UI surfaces; package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today.
### The recorded scenario and the harness op ### The recorded scenario and the harness op
@@ -108,11 +110,11 @@ No new cordis event is declared (`mode/set` rides `session/event`; the listeners
Plan mode is one feature and lands as one. An agent that can be locked into planning but has no sanctioned way to propose leaving it is not a smaller version of the feature — it is a different and worse one, where every plan ends with the model asking the user to flip a switch it cannot see. The two stages below are therefore build-and-review order for one stacked landing ([stacked-review guide](../../../cookbook/responding-to-pr-review-on-a-stack.md)): stage 2 stacks on stage 1 and the stack merges together; neither stage is a shippable milestone on its own. Plan mode is one feature and lands as one. An agent that can be locked into planning but has no sanctioned way to propose leaving it is not a smaller version of the feature — it is a different and worse one, where every plan ends with the model asking the user to flip a switch it cannot see. The two stages below are therefore build-and-review order for one stacked landing ([stacked-review guide](../../../cookbook/responding-to-pr-review-on-a-stack.md)): stage 2 stacks on stage 1 and the stack merges together; neither stage is a shippable milestone on its own.
The one hard prerequisite is the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`): the exit approval is its `ask` routing end to end. It is already implemented on `feat/sandbox-support`, so the coupling is merge order, not unbuilt work — this stack bases on that branch until it lands on master. The one hard prerequisite is the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`): the exit approval is its `ask` routing end to end. It is already implemented on `feat/sandbox-support`, so the coupling is merge order, not unbuilt work — this stack bases on that branch until it lands on master. The wider in-flight neighborhood is convergent, not conflicting: the sandbox-escalation branch ships the first live approval composition (its example and scripted-answer harness are the precedent our recorded scenarios follow), the env-state proposal pins the same fold-from-log + boundary-application idiom for environment facts (its `session/set_mode` config-phase sketch is the single coordination point, resolved in Protocol and UI surfaces), and the user-interaction seam supplies the stdio answerer's stdin discipline where mounted.
### Stage 1 — the mode core ### Stage 1 — the mode core
The `dsh-mode` package: `mode/set` + `foldMode`, the assemble filter and mode section, the pre-execute gate, validated Config with the `plan` definition, `ctx.modes` with pending-intent flush, the `AgentOptions.mode` merge, and the stdio toggle. Coverage named at plan time: unit tier for the fold, the filter, the gate matrix, and the flush mechanics; a snapshot scenario pinning `mode/set` plus the consequent `request/header-delta` in `session.jsonl`; all existing goldens byte-identical (default mode is invisible). Docs tail in the same stage: package + group READMEs, the [packages map](../../../../packages/README.md) row, regenerated persistence/config catalogs, and the cookbook's plan-mode row upgraded from sketch to package pointer. The `dsh-mode` package: `mode/set` + `foldMode`, the assemble filter and mode section, the pre-execute gate, validated Config with the `plan` definition, `ctx.modes` with pending-intent flush, the `AgentOptions.mode` merge, and the stdio toggle. Coverage named at plan time: unit tier for the fold, the filter, the gate matrix, the flush mechanics, and the coalesced boundary notice; a snapshot scenario pinning `mode/set` plus the consequent `request/header-delta` in `session.jsonl`; all existing goldens byte-identical (default mode is invisible). Docs tail in the same stage: package + group READMEs, the [packages map](../../../../packages/README.md) row, regenerated persistence/config catalogs, and the cookbook's plan-mode row upgraded from sketch to package pointer.
### Stage 2 — the exit loop and the protocol surface ### Stage 2 — the exit loop and the protocol surface
@@ -145,6 +147,7 @@ Deferred beyond this landing, each behind its own decision: subagent mode inheri
## Acceptance criteria ## Acceptance criteria
- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by the matching `request/header-delta` on the next step with the dev invariant green throughout. - The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by the matching `request/header-delta` on the next step with the dev invariant green throughout.
- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result.
- In the default mode the plugin is invisible: assemblies are byte-identical with and without `dsh-mode` loaded, and every pre-existing snapshot golden is unchanged. - In the default mode the plugin is invisible: assemblies are byte-identical with and without `dsh-mode` loaded, and every pre-existing snapshot golden is unchanged.
- In plan mode the filtered schemas and mode section reach both the wire request and the logged header; a call to a registered-but-filtered mutating tool is denied at `tools/pre-execute` with the mode-naming reason. - In plan mode the filtered schemas and mode section reach both the wire request and the logged header; a call to a registered-but-filtered mutating tool is denied at `tools/pre-execute` with the mode-naming reason.
- Mode definitions (allowlist, section text) are changeable from `cordis.yml` with no code edit; an unknown mode name fails validation loudly at `set()` time. - Mode definitions (allowlist, section text) are changeable from `cordis.yml` with no code edit; an unknown mode name fails validation loudly at `set()` time.
@@ -153,4 +156,4 @@ Deferred beyond this landing, each behind its own decision: subagent mode inheri
## Risks ## Risks
A pending user flip set while idle is lost if the process dies before the next turn — accepted (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. Sibling-listener order is not deterministic, so a foreign assemble listener wrapping OUTSIDE the mode listener could re-widen filtered schemas — the filter runs on the assembly `next()` returns (so everything inside its wrap is covered), and the hard gate keeps anything re-widened non-executable; the residual cost is cosmetic (the model sees a tool it cannot use), accepted rather than mechanized. Plan mode's shipped allowlist excludes `bash` and `subagent`, which costs real exploration power (no `git log`, no read-only delegate) until the sandbox family and mode inheritance land — a deployment that accepts the risk can widen its own config today. The whole landing gates on the approval seam merging first — a deliberate schedule coupling accepted in place of shipping the mode core alone (an incomplete feature, per the roadmap); the seam is implemented on its branch, and this stack bases on it meanwhile. A deployment that composes no answerer keeps a safe but manual plan mode (`ask` → `unavailable` → deny), and the mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user if that is denied — so it never thrashes against the gate. Branch-heavy policy code under the per-file 100% coverage gate is real work, accepted as the ACP bridge did. A pending user flip set while idle is lost if the process dies before the next turn — accepted (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. Sibling-listener order is not deterministic, so a foreign assemble listener wrapping OUTSIDE the mode listener could re-widen filtered schemas — the filter runs on the assembly `next()` returns (so everything inside its wrap is covered), and the hard gate keeps anything re-widened non-executable; the residual cost is cosmetic (the model sees a tool it cannot use), accepted rather than mechanized. Plan mode's shipped allowlist excludes `bash` and `subagent`, which costs real exploration power (no `git log`, no read-only delegate) until the sandbox family and mode inheritance land — a deployment that accepts the risk can widen its own config today. The whole landing gates on the approval seam merging first — a deliberate schedule coupling accepted in place of shipping the mode core alone (an incomplete feature, per the roadmap); the seam is implemented on its branch, and this stack bases on it meanwhile. A deployment that composes no answerer keeps a safe but manual plan mode (`ask` → `unavailable` → deny), and the mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user if that is denied — so it never thrashes against the gate. Two in-flight proposals touch the ACP mode surface (this one and the env-state config phase): the picker-to-modes / knobs-to-config-options division in Protocol and UI surfaces is the proposed contract, landing order decides who wires `session/set_mode`, and the second lander owes the amendment. Branch-heavy policy code under the per-file 100% coverage gate is real work, accepted as the ACP bridge did.