Skip to content

Commit 6f8b2bb

Browse files
Merge pull request #1725 from XiaomiMiMo/feat/sticky-agent-mode
feat: sticky agent mode — mode locking + permission-based skill scoping
2 parents 6ceb0a8 + b599b0a commit 6f8b2bb

31 files changed

Lines changed: 254 additions & 82 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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

packages/opencode/src/agent/agent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export const layer = Layer.effect(
105105
const defaults = Permission.fromConfig({
106106
"*": "allow",
107107
doom_loop: "ask",
108+
skill: {
109+
"*": "allow",
110+
"compose:*": "deny",
111+
},
112+
plan_enter: "deny",
113+
plan_exit: "deny",
108114
external_directory: {
109115
"*": "ask",
110116
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
@@ -131,6 +137,8 @@ export const layer = Layer.effect(
131137
defaults,
132138
Permission.fromConfig({
133139
question: "allow",
140+
plan_enter: "allow",
141+
plan_exit: "allow",
134142
}),
135143
user,
136144
),
@@ -169,6 +177,8 @@ export const layer = Layer.effect(
169177
defaults,
170178
Permission.fromConfig({
171179
question: "allow",
180+
plan_enter: "allow",
181+
plan_exit: "allow",
172182
external_directory: {
173183
[path.join(Global.Path.data, "plans", "*")]: "allow",
174184
},
@@ -206,6 +216,7 @@ export const layer = Layer.effect(
206216
defaults,
207217
Permission.fromConfig({
208218
question: "allow",
219+
skill: { "compose:*": "allow" },
209220
}),
210221
user,
211222
),

packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function DialogAgent() {
2323
current={local.agent.current()?.name}
2424
options={options()}
2525
onSelect={(option) => {
26-
local.agent.set(option.value)
26+
local.agent.userSwitch(option.value)
2727
dialog.clear()
2828
}}
2929
/>

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export function Prompt(props: PromptProps) {
191191

192192
function voiceSwitchAgent(name: string) {
193193
const match = local.agent.list().find((x) => x.name.toLowerCase() === name.toLowerCase())
194-
if (match) local.agent.set(match.name)
194+
if (match) local.agent.userSwitch(match.name)
195195
else toast.show({ message: t("tui.voice.error.unknown_agent", { name: name }), variant: "error", duration: 3000 })
196196
}
197197

@@ -507,6 +507,11 @@ export function Prompt(props: PromptProps) {
507507
),
508508
)
509509

510+
// Derive sticky mode from whether session has messages
511+
createEffect(() => {
512+
local.agent.setSessionHasMessages(!!lastUserMessage())
513+
})
514+
510515
// Initialize agent/model/variant from last user message when session changes
511516
let syncedSessionID: string | undefined
512517
createEffect(() => {
@@ -515,7 +520,6 @@ export function Prompt(props: PromptProps) {
515520

516521
if (sessionID !== syncedSessionID) {
517522
if (!sessionID || !msg) return
518-
519523
syncedSessionID = sessionID
520524

521525
// Only set agent if it's a primary agent (not a subagent)

packages/opencode/src/cli/cmd/tui/context/local.tsx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
4949
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
5050
const [agentStore, setAgentStore] = createStore({
5151
current: undefined as string | undefined,
52+
sessionHasMessages: false,
5253
})
54+
const FREE_SWITCH_GROUP = ["build", "plan"]
55+
const canSwitchTo = (target: string) => {
56+
if (!agentStore.sessionHasMessages) return true
57+
const current = agentStore.current
58+
if (!current) return true
59+
if (current === target) return true
60+
const currentInGroup = FREE_SWITCH_GROUP.includes(current)
61+
const targetInGroup = FREE_SWITCH_GROUP.includes(target)
62+
return currentInGroup && targetInGroup
63+
}
64+
const switchBlockedToast = () => {
65+
const current = agentStore.current ?? ""
66+
if (FREE_SWITCH_GROUP.includes(current)) {
67+
toast.show({
68+
variant: "warning",
69+
message: t("tui.agent.locked.subset", { agents: FREE_SWITCH_GROUP.join(", ") }),
70+
duration: 3000,
71+
})
72+
} else {
73+
toast.show({
74+
variant: "warning",
75+
message: t("tui.agent.locked", { mode: current }),
76+
duration: 3000,
77+
})
78+
}
79+
}
5380
const { theme } = useTheme()
5481
const colors = createMemo(() => [
5582
theme.secondary,
@@ -76,16 +103,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
76103
})
77104
setAgentStore("current", name)
78105
},
106+
userSwitch(name: string) {
107+
if (!canSwitchTo(name)) {
108+
switchBlockedToast()
109+
return
110+
}
111+
this.set(name)
112+
},
79113
move(direction: 1 | -1) {
80-
batch(() => {
81-
const current = this.current()
82-
if (!current) return
83-
let next = agents().findIndex((x) => x.name === current.name) + direction
84-
if (next < 0) next = agents().length - 1
85-
if (next >= agents().length) next = 0
86-
const value = agents()[next]
87-
setAgentStore("current", value.name)
88-
})
114+
const current = this.current()
115+
if (!current) return
116+
const list = agents()
117+
const currentIdx = list.findIndex((x) => x.name === current.name)
118+
for (let i = 1; i < list.length; i++) {
119+
let idx = currentIdx + direction * i
120+
idx = ((idx % list.length) + list.length) % list.length
121+
const candidate = list[idx]
122+
if (!candidate) continue
123+
if (canSwitchTo(candidate.name)) {
124+
setAgentStore("current", candidate.name)
125+
return
126+
}
127+
}
128+
switchBlockedToast()
129+
},
130+
setSessionHasMessages(value: boolean) {
131+
setAgentStore("sessionHasMessages", value)
89132
},
90133
color(name: string) {
91134
const index = visibleAgents().findIndex((x) => x.name === name)

packages/opencode/src/cli/cmd/tui/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ export const dict: Record<string, string> = {
275275
"tui.command.variant.cycle.title": "Variant cycle",
276276
"tui.command.variant.list.title": "Switch model variant",
277277
"tui.command.agent.cycle.reverse.title": "Agent cycle reverse",
278+
"tui.agent.locked": "Cannot switch mode mid-session after entering {{mode}} mode",
279+
"tui.agent.locked.subset": "In this session, you can only switch between {{agents}}",
278280
"tui.command.provider.login.title": "Login",
279281
"tui.command.provider.connect.title": "Connect provider",
280282
"tui.command.provider.logout.title": "Logout",

packages/opencode/src/cli/cmd/tui/i18n/es.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ export const dict = {
351351
"tui.command.variant.cycle.title": "Ciclo de variantes",
352352
"tui.command.variant.list.title": "Cambiar variante de modelo",
353353
"tui.command.agent.cycle.reverse.title": "Ciclo de agentes (inverso)",
354+
"tui.agent.locked": "No se puede cambiar de modo después de entrar en modo {{mode}}",
355+
"tui.agent.locked.subset": "En esta sesión, solo puede cambiar entre {{agents}}",
354356
"tui.command.provider.login.title": "Iniciar sesión",
355357
"tui.command.provider.connect.title": "Conectar proveedor",
356358
"tui.command.provider.logout.title": "Cerrar sesión",

packages/opencode/src/cli/cmd/tui/i18n/fr.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ export const dict = {
339339
"tui.command.variant.cycle.title": "Cycle de variantes",
340340
"tui.command.variant.list.title": "Changer de variante de modèle",
341341
"tui.command.agent.cycle.reverse.title": "Cycle d'agents (inverse)",
342+
"tui.agent.locked": "Impossible de changer de mode après être entré en mode {{mode}}",
343+
"tui.agent.locked.subset": "Dans cette session, vous pouvez uniquement basculer entre {{agents}}",
342344
"tui.command.provider.login.title": "Connexion",
343345
"tui.command.provider.connect.title": "Connecter un fournisseur",
344346
"tui.command.provider.logout.title": "Déconnexion",

packages/opencode/src/cli/cmd/tui/i18n/ja.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ export const dict = {
284284
"tui.command.variant.cycle.title": "バリアントを循環",
285285
"tui.command.variant.list.title": "モデルバリアントを切り替え",
286286
"tui.command.agent.cycle.reverse.title": "エージェントを逆循環",
287+
"tui.agent.locked": "{{mode}} モードに入った後はモードを切り替えできません",
288+
"tui.agent.locked.subset": "このセッションでは {{agents}} の間でのみ切り替え可能です",
287289
"tui.command.provider.login.title": "ログイン",
288290
"tui.command.provider.connect.title": "プロバイダに接続",
289291
"tui.command.provider.logout.title": "ログアウト",

packages/opencode/src/cli/cmd/tui/i18n/ru.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ export const dict = {
354354
"tui.command.variant.cycle.title": "Цикл вариантов",
355355
"tui.command.variant.list.title": "Сменить вариант модели",
356356
"tui.command.agent.cycle.reverse.title": "Цикл агентов (в обратном порядке)",
357+
"tui.agent.locked": "Невозможно сменить режим после входа в {{mode}}",
358+
"tui.agent.locked.subset": "В этой сессии можно переключаться только между {{agents}}",
357359
"tui.command.provider.login.title": "Войти",
358360
"tui.command.provider.connect.title": "Подключить провайдера",
359361
"tui.command.provider.logout.title": "Выйти",

0 commit comments

Comments
 (0)