#18516 · @BYK · opened Mar 21, 2026 at 11:50 AM UTC · last updated Mar 21, 2026 at 7:35 PM UTC

fix(plan): prevent subagent plan escape, show plan in exit prompt, render as markdown

appfix
62
+9366 files

Score breakdown

Impact

7.0

Clarity

9.0

Urgency

6.0

Ease Of Review

8.0

Guidelines

8.0

Readiness

8.0

Size

6.0

Trust

5.0

Traction

2.0

Summary

This PR fixes three critical issues in the experimental planning mode, preventing subagents from prematurely exiting the plan, displaying the full plan content before building, and correctly rendering markdown in the question dock. It improves the stability and user experience of this feature.

Open in GitHub

Description

Issue for this PR

Closes #18515

Type of change

  • [x] Bug fix
  • [ ] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Fixes three issues with the experimental planning mode (OPENCODE_EXPERIMENTAL_PLAN_MODE):

1. Subagents can escape plan mode — When the plan agent spawns explore/general subagents via the task tool, those child sessions had access to plan_exit. If a subagent called it and the user accidentally confirmed, the session would switch to build mode prematurely. Fix: deny plan_exit and plan_enter permissions in subagent session creation (task.ts), alongside the existing todowrite/todoread denials.

2. Plan not shown before build switchplan_exit asked "Would you like to switch?" without showing what the plan actually says. The user had to go read the plan file separately. Fix: read the plan file content in PlanExitTool and append it to the question text below a horizontal rule, so users see the full plan inline before deciding.

3. Question text renders as plain text — The question dock rendered question.question as raw text insertion, not markdown. Even with the plan content included, headers/lists/code blocks would display unformatted. Fix: use the Markdown component in both the live question dock (session-question-dock.tsx) and historical message display (message-part.tsx). Added overflow-y: auto; max-height: 40vh to the question-text CSS so long plans scroll within the dock.

How did you verify your code works?

  • Reviewed the permission deny logic matches the existing pattern for todowrite/todoread
  • Verified Markdown component is already used throughout the UI (message parts, context tabs) and the import path is correct
  • The CSS change is additive and scoped to [data-slot="question-text"] within the question dock

Screenshots / recordings

N/A — planning mode is experimental and behind a flag. The markdown rendering change applies to all question docks.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#18515 Planning mode: subagents can trigger plan_exit, plan not shown before build switch, question text renders as plain text

View issue

Comments

No comments.

Changed Files

AGENTS.md

+672
@@ -123,6 +123,71 @@ const table = sqliteTable("session", {
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
<!-- This section is maintained by the coding agent via lore (https://github.com/BYK/opencode-lore) -->
## Long-term Knowledge
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
### Architecture
<!-- lore:4e7ecb18-e26f-4f13-a50e-567eda334a64 -->
* **Nuum eval baseline score: 72.6% on LongMemEval oracle**: Nuum eval scores (LongMemEval oracle, 500 questions, claude-sonnet-4-6): Baseline 72.6%, v1 73.8%, v2 final 88.0% (+14.2pp over v1). Key category gains: multi-session 85.1% (+20.6pp), temporal-reasoning 81.9% (+22.8pp), single-session-assistant 96.4% (recovered from 57.1% regression). Coding eval (15 questions): Default 10/15 (66.7%), Nuum 14/15 (93.3%). Results in eval/results/. Coding eval has two modes: Default uses 80k tail window (no tools), Nuum injects distilled observations + recall tool for FTS search fallback.
<!-- lore:8afece67-a241-4000-983a

packages/app/src/pages/session/composer/session-question-dock.tsx

+41
@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { Markdown } from "@opencode-ai/ui/markdown"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -301,7 +302,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text">
<Markdown text={question()?.question ?? ""} />
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>

packages/opencode/src/tool/plan.ts

+72
@@ -21,12 +21,17 @@ export const PlanExitTool = Tool.define("plan_exit", {
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const abs = Session.plan(session)
const plan = path.relative(Instance.worktree, abs)
const content = await Bun.file(abs)
.text()
.catch(() => "")
const preview = content ? `\n\n---\n\n${content}` : ""
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
question: `Plan at \`${plan}\` is complete. Would you like to switch to the build agent and start implementing?${preview}`,
header: "Build Agent",
custom: false,
options: [

packages/opencode/src/tool/task.ts

+100
@@ -85,6 +85,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
pattern: "*",
action: "deny",
},
{
permission: "plan_exit",
pattern: "*",
action: "deny",
},
{
permission: "plan_enter",
pattern: "*",
action: "deny",
},
...(hasTaskPermission
? []
: [

packages/ui/src/components/message-part.css

+20
@@ -908,6 +908,8 @@
line-height: var(--line-height-large);
color: var(--text-strong);
padding: 0 10px;
overflow-y: auto;
max-height: 40vh;
}
[data-slot="question-hint"] {

packages/ui/src/components/message-part.tsx

+31
@@ -2184,7 +2184,9 @@ ToolRegistry.register({
const answer = () => answers()[i()] ?? []
return (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<div data-slot="question-text">
<Markdown text={q.question} />
</div>
<div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
</div>
)