Skip to content

Commit a7ddbb0

Browse files
exp: plan tools permission-gated + differentiated switch-blocked toast
- plan_enter/plan_exit: deny in compose agent permission (agent.ts) - registry.ts reads permission to filter, no hardcoded agent names - switchBlockedToast: shows subset message (build/plan) or locked message (compose) - i18n: add tui.agent.locked.subset in all locales
1 parent 177b840 commit a7ddbb0

11 files changed

Lines changed: 83 additions & 27 deletions

File tree

docs/compose/reports/sticky-agent-mode.md

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ status: experimental
44
specs: []
55
plans: []
66
branch: exp/sticky-agent-mode
7-
commits: e418785e..fe72d4e2
7+
commits: e418785e..6760950e
88
---
99

1010
# Sticky Agent Mode — Final Report
@@ -13,7 +13,11 @@ commits: e418785e..fe72d4e2
1313

1414
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`.
1515

16-
Alongside this, compose skills are migrated from `hidden: true` to permission-based scoping. The compose agent's permission allows `compose:*` skills; all other agents deny them. This eliminates the hardcoded `composeSkillsBlock()` injection — compose skills appear in the normal system prompt skill listing when the compose agent is active.
16+
Three supporting changes enable a clean per-mode experience:
17+
18+
1. **Permission-based skill scoping** — compose skills use `deny`/`allow` permissions instead of `hidden: true`
19+
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)
20+
3. **Removed `composeSkillsBlock()` injection** — compose skills appear naturally in the system prompt via `available(agent)`
1721

1822
## Architecture
1923

@@ -23,10 +27,11 @@ Alongside this, compose skills are migrated from `hidden: true` to permission-ba
2327

2428
- `agentStore.sessionHasMessages` — reactive boolean derived from `!!lastUserMessage()`
2529
- `FREE_SWITCH_GROUP = ["build", "plan"]` — agents that can freely switch between each other
26-
- `canSwitchTo(target)` — returns true if: no messages yet, OR both current and target are in the same group
30+
- `canSwitchTo(target)` — returns true if: no messages yet, OR target is self, OR both current and target are in the same group
2731
- `set(name)` — unguarded, for system/programmatic use (session restore, plan tools, CLI)
28-
- `userSwitch(name)` — guarded, for user actions (dialog, voice). Shows toast when blocked
32+
- `userSwitch(name)` — guarded, for user actions (dialog, voice). Shows contextual toast when blocked
2933
- `move(direction)` — cycles through agents, skipping blocked ones. Toast only when no valid target exists
34+
- `switchBlockedToast()` — shows subset message (build/plan group) or locked message (compose isolated)
3035

3136
**File:** `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`
3237

@@ -49,9 +54,16 @@ Single reactive effect — no manual lock/unlock. Naturally handles `/new` (empt
4954

5055
- `Skill.available()` no longer filters `!sk.hidden` — relies entirely on permission
5156

52-
**File:** `packages/opencode/src/skill/compose/.bundle/*/SKILL.md`
57+
### Plan Tools Scoping
58+
59+
**File:** `packages/opencode/src/agent/agent.ts`
60+
61+
- `defaults` includes `plan_enter: "deny"`, `plan_exit: "deny"`
62+
- Build agent: `plan_enter: "allow"`, `plan_exit: "allow"`
63+
- Plan agent: `plan_enter: "allow"`, `plan_exit: "allow"`
64+
- Both agents see BOTH tools (symmetric, no list mutation on build↔plan switch)
5365

54-
- All 14 compose skills: `hidden: true` removed from frontmatter
66+
**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.
5567

5668
### Compose Prompt Cleanup
5769

@@ -63,7 +75,7 @@ Single reactive effect — no manual lock/unlock. Naturally handles `/new` (empt
6375
**File:** `packages/opencode/src/session/prompt/compose.txt`
6476

6577
- "Compose Skills Visibility" section simplified: skills are in the normal listing
66-
- Subagent guidance: "distill instructions" instead of "pass skill lists"
78+
- Subagent guidance: "distill instructions into prompts"
6779

6880
### Design Decisions
6981

@@ -73,34 +85,42 @@ Single reactive effect — no manual lock/unlock. Naturally handles `/new` (empt
7385

7486
**`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).
7587

76-
**Permission in defaults (deny) + compose override (allow):** Future agents automatically inherit the `compose:*` deny. Only the compose agent explicitly opts in. This is more future-proof than denying on each non-compose agent individually.
88+
**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).
89+
90+
**Contextual toast messages:** Two variants — "只能在 build, plan 之间切换" when in the group but target is outside, "进入 compose 模式后无法切换" when isolated with no valid targets.
91+
92+
**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.
7793

78-
**Subagent distillation model:** Compose skills instruct the orchestrator to distill guidance into concrete subagent prompts, rather than passing `<available_skills>` blocks. Subagents are leaf workers — they don't need orchestration skills.
94+
**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.
7995

8096
## Usage
8197

8298
This is an experimental branch (`exp/sticky-agent-mode`). Behavior:
8399

84100
- **New session:** Mode selector works normally (Tab cycles all agents)
85101
- **After first message:** Mode is locked to the current group
86-
- Build/Plan: can Tab between them freely
102+
- Build/Plan: Tab cycles between them. Toast: "只能在 build, plan 之间切换" if trying `/agents` to compose
87103
- Compose: Tab shows toast — "进入 compose 模式后无法在运行中切换模式"
88104
- **`/new`:** Creates empty session → mode unlocked again
89105
- **`/session`:** Enters existing session → mode locked to that session's agent
106+
- **Self-switch:** Always allowed (no-op, no toast)
90107

91108
## Verification
92109

93110
- `bun typecheck` — clean (0 errors)
94111
- `bun test test/session/prompt-skill-mention.test.ts` — 8/8 pass
95112
- `bun test test/permission` — 141/141 pass
96-
- Manual TUI testing: Tab cycling, `/session`, `/new`, toast messages
113+
- Manual TUI testing: Tab cycling, `/session`, `/new`, `/agents` dialog, toast messages
97114

98115
## Journey Log
99116

100117
> Brief notes on what informed the final design.
101118
102119
- [dead end] `lock()` boolean — persisted after `/new`, broke mode switching on new sessions
103120
- [dead end] Guard in `set()` with `{ force: true }` bypass — fragile, missed system call sites
121+
- [dead end] Hardcoded plan tool filter in registry.ts — unnecessary, `llm.ts:resolveTools` already uses `Permission.disabled()` generically
104122
- [pivot] Switched to reactive `!!lastUserMessage()` derivation — handles all session transitions naturally
105123
- [pivot] Separated `set()` (system) from `userSwitch()` (user) — eliminated the whitelist problem entirely
106-
- [lesson] `move()` must skip blocked agents, not stop at the first one — otherwise Tab breaks within the allowed group
124+
- [pivot] Plan tools: defaults deny + build/plan allow (symmetric) — differs from pre-#1207 (asymmetric) but stays stable within the group
125+
- [lesson] `move()` must skip blocked agents, not stop at the first one
126+
- [lesson] Self-switch (`current === target`) must always pass — otherwise no-op triggers false toast

packages/opencode/src/agent/agent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export const layer = Layer.effect(
109109
"*": "allow",
110110
"compose:*": "deny",
111111
},
112+
plan_enter: "deny",
113+
plan_exit: "deny",
112114
external_directory: {
113115
"*": "ask",
114116
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
@@ -135,6 +137,8 @@ export const layer = Layer.effect(
135137
defaults,
136138
Permission.fromConfig({
137139
question: "allow",
140+
plan_enter: "allow",
141+
plan_exit: "allow",
138142
}),
139143
user,
140144
),
@@ -173,6 +177,8 @@ export const layer = Layer.effect(
173177
defaults,
174178
Permission.fromConfig({
175179
question: "allow",
180+
plan_enter: "allow",
181+
plan_exit: "allow",
176182
external_directory: {
177183
[path.join(Global.Path.data, "plans", "*")]: "allow",
178184
},

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
5656
if (!agentStore.sessionHasMessages) return true
5757
const current = agentStore.current
5858
if (!current) return true
59+
if (current === target) return true
5960
const currentInGroup = FREE_SWITCH_GROUP.includes(current)
6061
const targetInGroup = FREE_SWITCH_GROUP.includes(target)
6162
return currentInGroup && targetInGroup
6263
}
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+
}
6380
const { theme } = useTheme()
6481
const colors = createMemo(() => [
6582
theme.secondary,
@@ -88,11 +105,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
88105
},
89106
userSwitch(name: string) {
90107
if (!canSwitchTo(name)) {
91-
toast.show({
92-
variant: "warning",
93-
message: t("tui.agent.locked", { mode: agentStore.current ?? "" }),
94-
duration: 3000,
95-
})
108+
switchBlockedToast()
96109
return
97110
}
98111
this.set(name)
@@ -113,11 +126,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
113126
return
114127
}
115128
}
116-
toast.show({
117-
variant: "warning",
118-
message: t("tui.agent.locked", { mode: agentStore.current ?? "" }),
119-
duration: 3000,
120-
})
129+
switchBlockedToast()
121130
})
122131
},
123132
setSessionHasMessages(value: boolean) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export const dict: Record<string, string> = {
276276
"tui.command.variant.list.title": "Switch model variant",
277277
"tui.command.agent.cycle.reverse.title": "Agent cycle reverse",
278278
"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}}",
279280
"tui.command.provider.login.title": "Login",
280281
"tui.command.provider.connect.title": "Connect provider",
281282
"tui.command.provider.logout.title": "Logout",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export const dict = {
352352
"tui.command.variant.list.title": "Cambiar variante de modelo",
353353
"tui.command.agent.cycle.reverse.title": "Ciclo de agentes (inverso)",
354354
"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}}",
355356
"tui.command.provider.login.title": "Iniciar sesión",
356357
"tui.command.provider.connect.title": "Conectar proveedor",
357358
"tui.command.provider.logout.title": "Cerrar sesión",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export const dict = {
340340
"tui.command.variant.list.title": "Changer de variante de modèle",
341341
"tui.command.agent.cycle.reverse.title": "Cycle d'agents (inverse)",
342342
"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}}",
343344
"tui.command.provider.login.title": "Connexion",
344345
"tui.command.provider.connect.title": "Connecter un fournisseur",
345346
"tui.command.provider.logout.title": "Déconnexion",

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

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

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

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export const dict = {
261261
"tui.command.variant.list.title": "切换模型变体",
262262
"tui.command.agent.cycle.reverse.title": "反向循环切换智能体",
263263
"tui.agent.locked": "进入 {{mode}} 模式后无法在运行中切换模式",
264+
"tui.agent.locked.subset": "已开始的会话中,只能在 {{agents}} 之间切换",
264265
"tui.command.provider.login.title": "登录",
265266
"tui.command.provider.connect.title": "连接服务商",
266267
"tui.command.provider.logout.title": "登出",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export const dict = {
261261
"tui.command.variant.list.title": "切換模型變體",
262262
"tui.command.agent.cycle.reverse.title": "反向循環切換智慧代理",
263263
"tui.agent.locked": "進入 {{mode}} 模式後無法在運行中切換模式",
264+
"tui.agent.locked.subset": "已開始的會話中,只能在 {{agents}} 之間切換",
264265
"tui.command.provider.login.title": "登入",
265266
"tui.command.provider.connect.title": "連線供應商",
266267
"tui.command.provider.logout.title": "登出",

0 commit comments

Comments
 (0)