|
| 1 | +--- |
| 2 | +feature: sticky-agent-mode |
| 3 | +status: proposed |
| 4 | +specs: [] |
| 5 | +plans: [] |
| 6 | +branch: feat/sticky-agent-mode |
| 7 | +--- |
| 8 | + |
| 9 | +# Sticky Agent Mode — Final Report |
| 10 | + |
| 11 | +## What Was Built |
| 12 | + |
| 13 | +An experimental mode-locking system where agent selection is permanent for the duration of a session. Once a session has content (any user message), the user cannot switch to a different mode group. Build and Plan form a single free-switch group; all other agents (Compose, etc.) are isolated — once you're in Compose, you stay in Compose until `/new`. |
| 14 | + |
| 15 | +Three supporting changes enable a clean per-mode experience: |
| 16 | + |
| 17 | +1. **Permission-based skill scoping** — compose skills use `deny`/`allow` permissions instead of `hidden: true` |
| 18 | +2. **Plan tools scoped to build/plan** — `plan_enter`/`plan_exit` denied by default, allowed only for build/plan (keeping compose's tool list clean) |
| 19 | +3. **Removed `composeSkillsBlock()` injection** — compose skills appear naturally in the system prompt via `available(agent)` |
| 20 | + |
| 21 | +## Architecture |
| 22 | + |
| 23 | +### Sticky Mode (TUI) |
| 24 | + |
| 25 | +**File:** `packages/opencode/src/cli/cmd/tui/context/local.tsx` |
| 26 | + |
| 27 | +- `agentStore.sessionHasMessages` — reactive boolean derived from `!!lastUserMessage()` |
| 28 | +- `FREE_SWITCH_GROUP = ["build", "plan"]` — agents that can freely switch between each other |
| 29 | +- `canSwitchTo(target)` — returns true if: no messages yet, OR target is self, OR both current and target are in the same group |
| 30 | +- `set(name)` — unguarded, for system/programmatic use (session restore, plan tools, CLI) |
| 31 | +- `userSwitch(name)` — guarded, for user actions (dialog, voice). Shows contextual toast when blocked |
| 32 | +- `move(direction)` — cycles through agents, skipping blocked ones. Toast only when no valid target exists |
| 33 | +- `switchBlockedToast()` — shows subset message (build/plan group) or locked message (compose isolated) |
| 34 | + |
| 35 | +**File:** `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` |
| 36 | + |
| 37 | +```ts |
| 38 | +createEffect(() => { |
| 39 | + local.agent.setSessionHasMessages(!!lastUserMessage()) |
| 40 | +}) |
| 41 | +``` |
| 42 | + |
| 43 | +Single reactive effect — no manual lock/unlock. Naturally handles `/new` (empty session = unlocked), `/session` (has messages = locked), and message submission. |
| 44 | + |
| 45 | +### Permission-Based Skill Scoping |
| 46 | + |
| 47 | +**File:** `packages/opencode/src/agent/agent.ts` |
| 48 | + |
| 49 | +- `defaults` includes `skill: { "*": "allow", "compose:*": "deny" }` |
| 50 | +- Compose agent overrides with `skill: { "compose:*": "allow" }` |
| 51 | + |
| 52 | +**File:** `packages/opencode/src/skill/index.ts` |
| 53 | + |
| 54 | +- `Skill.available()` no longer filters `!sk.hidden` — relies entirely on permission |
| 55 | + |
| 56 | +### Plan Tools Scoping |
| 57 | + |
| 58 | +**File:** `packages/opencode/src/agent/agent.ts` |
| 59 | + |
| 60 | +- `defaults` includes `plan_enter: "deny"`, `plan_exit: "deny"` |
| 61 | +- Build agent: `plan_enter: "allow"`, `plan_exit: "allow"` |
| 62 | +- Plan agent: `plan_enter: "allow"`, `plan_exit: "allow"` |
| 63 | +- Both agents see BOTH tools (symmetric, no list mutation on build↔plan switch) |
| 64 | + |
| 65 | +**No registry.ts changes needed** — `llm.ts:resolveTools()` already uses `Permission.disabled()` to strip denied tools before sending them to the model. The permission rules in agent.ts are sufficient. |
| 66 | + |
| 67 | +### Compose Prompt Cleanup |
| 68 | + |
| 69 | +**File:** `packages/opencode/src/session/prompt.ts` |
| 70 | + |
| 71 | +- `composeSkillsBlock()` import and call removed |
| 72 | +- Only `{{compose_docs_dir}}` substitution remains in PROMPT_COMPOSE |
| 73 | + |
| 74 | +**File:** `packages/opencode/src/session/prompt/compose.txt` |
| 75 | + |
| 76 | +- "Compose Skills Visibility" section simplified: skills are in the normal listing |
| 77 | +- Subagent guidance: "distill instructions into prompts" |
| 78 | + |
| 79 | +### Design Decisions |
| 80 | + |
| 81 | +**`set()` vs `userSwitch()` separation:** The guard only applies to user-initiated actions (Tab, dialog, voice). System paths (session restore, plan_enter/plan_exit, CLI --agent) use `set()` directly and are never blocked. This avoids a fragile whitelist of "force" call sites. |
| 82 | + |
| 83 | +**Reactive `sessionHasMessages` from `lastUserMessage()`:** No manual lock/unlock state. The signal is derived from actual session content, so `/new`, `/session`, and submits all work correctly without explicit handling. |
| 84 | + |
| 85 | +**`move()` skips blocked agents:** Tab cycles within the allowed group instead of stopping at the first blocked agent. Toast only shows when the entire group has been exhausted (e.g., compose mode with no other compose-group agents). |
| 86 | + |
| 87 | +**Self-switch always allowed:** `canSwitchTo` returns true when `current === target`. Prevents false toast on no-op switches (e.g., compose user selecting compose in `/agents` dialog). |
| 88 | + |
| 89 | +**Contextual toast messages:** Two variants — "只能在 build, plan 之间切换" when in the group but target is outside, "进入 compose 模式后无法切换" when isolated with no valid targets. |
| 90 | + |
| 91 | +**Plan tools: symmetric allow in build/plan, deny in defaults:** Future agents automatically inherit the deny. Build and plan both see both tools (no list mutation within the group). This is NOT a revert of #1207 — #1207 made plan tools visible to ALL agents; this scopes them to the build/plan group only, possible because sticky mode prevents cross-group switching. |
| 92 | + |
| 93 | +**Permission in defaults (deny) + specific agent override (allow):** Used for both skills (`compose:*`) and tools (`plan_enter`/`plan_exit`). Future agents automatically inherit all deny rules. Only the relevant agent explicitly opts in. |
| 94 | + |
| 95 | +## Usage |
| 96 | + |
| 97 | +- **New session:** Mode selector works normally (Tab cycles all agents) |
| 98 | +- **After first message:** Mode is locked to the current group |
| 99 | + - Build/Plan: Tab cycles between them. Toast when trying to reach Compose |
| 100 | + - Compose: Tab shows toast — cannot switch mid-session |
| 101 | +- **`/new`:** Creates empty session → mode unlocked again |
| 102 | +- **`/session`:** Enters existing session → mode locked to that session's agent |
| 103 | +- **Self-switch:** Always allowed (no-op, no toast) |
| 104 | + |
| 105 | +## Verification |
| 106 | + |
| 107 | +- `bun typecheck` — clean |
| 108 | +- `bun test test/session/prompt-skill-mention.test.ts` — 8/8 pass |
| 109 | +- `bun test test/permission` — 141/141 pass |
| 110 | +- `bun test test/agent/agent.test.ts` — 48/48 pass |
| 111 | + |
| 112 | +## Design Notes |
| 113 | + |
| 114 | +Key constraints that shaped the final approach: |
| 115 | + |
| 116 | +- Guard must separate system paths (`set()`) from user actions (`userSwitch()`) — putting the guard directly in `set()` requires a fragile whitelist for session restore, plan tools, and CLI |
| 117 | +- Sticky state must be **derived** (`!!lastUserMessage()`), not manually managed — a boolean `lock()` breaks on `/new` and `/session` transitions |
| 118 | +- `move()` must skip blocked agents rather than stopping — otherwise Tab appears broken when the next agent in order is blocked |
| 119 | +- Plan tools use the existing `Permission.disabled()` pipeline in `llm.ts:resolveTools()` — no registry changes needed |
0 commit comments