Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Hold new goals created while a planning-only agent is active instead of starting them. Previously the idle guard caught a Plan-agent session only on the *next* idle, so `/goal <objective>` in Plan mode created a live goal and the routed command text still told the model to start working. Such a goal is now recorded with stop reason `plan agent active`, keeps its budget, and is announced through a read-only control turn that explicitly tells the model not to begin work.
- Add `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`) so the planning-only restriction can name other agents or be released entirely. The default-on behavior is pinned by the mutation contract.

## 0.8.2 — 2026-08-21

- Harden the post-compaction goal continuation guard introduced in
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,32 @@ await GoalPlugin(

`timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied.

## Plan-mode safety

A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default:

- A goal set while `plan` is active is **recorded but held**, with stop reason `plan agent active`. The objective and its budget survive, so nothing is lost — the goal simply does not start.
- The routed confirmation text for a held goal **omits the "start working" instruction** and is sent as a read-only control turn. This matters because command text reaches the model as a normal turn on current OpenCode builds (see [Limitations](#limitations)).
- Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop.
- Continuations retain the agent that started the goal, so the loop cannot drift into a different agent.

Run `/goal resume` after switching back to an executing agent to start the work.

| Option | Default | Controls |
|---|---|---|
| `restrictedAgents` | `["plan"]` | Agent names treated as planning-only (case-insensitive). Pass `[]` to release the restriction. |
| `allowGoalExecutionFromPlan` | `false` | Set `true` to allow goal creation and auto-continue while a restricted agent is active. |

```json
{
"plugin": [
["opencode-goal-plugin", { "restrictedAgents": ["plan", "review"] }]
]
}
```

The restriction being on by default is pinned by the mutation contract: hardcoding `allowGoalExecutionFromPlan` to `true` fails the suite.

## Prompt safety

The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
Expand Down
16 changes: 16 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,22 @@ export interface GoalPluginOptions {
/** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
registerAgents?: boolean

/**
* Agent names treated as planning-only. A goal created while one of these
* agents is active is recorded but held paused instead of starting, and
* auto-continue stays suppressed while one is active. Matching is
* case-insensitive. Pass `[]` to release the restriction entirely.
* @default ["plan"]
*/
restrictedAgents?: string[]

/**
* Opt out of the planning-only restriction, allowing goals to be created and
* auto-continued while a {@link restrictedAgents} agent is active.
* @default false
*/
allowGoalExecutionFromPlan?: boolean

/** Name of the native primary goal agent. @default "goal" */
goalAgentName?: string

Expand Down
7 changes: 7 additions & 0 deletions scripts/mutation-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ const mutants = [
to: (match) => match.replace(/\]\)$/, ', "prompt"])'),
test: "test/opencode-session-api.test.js",
},
{
name: "planning-only agents hold new goals unless explicitly opted out",
file: "src/goal-plugin.js",
from: "const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true",
to: "const allowGoalExecutionFromPlan = true",
test: "test/goal-plugin.test.js",
},
{
name: "completion evidence must be adjacent",
file: "src/goal-plugin.js",
Expand Down
127 changes: 111 additions & 16 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,37 @@ function continuationContextInput(goal) {
return context ? { ...context } : {}
}

// Planning-only agents must never be driven into execution by the goal loop.
// `plan` is OpenCode's built-in read-only agent; `restrictedAgents` lets a
// deployment name others (for example a review-only agent).
const DEFAULT_RESTRICTED_AGENTS = ["plan"]

function normalizeRestrictedAgents(value) {
// Anything that is not an array (including undefined) keeps the safe default;
// an explicit empty array is a deliberate opt-out.
if (!Array.isArray(value)) return [...DEFAULT_RESTRICTED_AGENTS]
const names = value
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
.filter(Boolean)
return [...new Set(names)]
}

function isRestrictedAgent(agent, restrictedAgents = DEFAULT_RESTRICTED_AGENTS) {
if (typeof agent !== "string") return false
const name = agent.trim().toLowerCase()
if (!name) return false
return restrictedAgents.includes(name)
}

function isPlanAgent(agent) {
return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS)
}

// Stop reason for a goal held because a planning-only agent is active. The
// built-in `plan` case keeps its established wording so persisted state and
// existing consumers stay stable.
function restrictedAgentStopReason(agent) {
return isPlanAgent(agent) ? "plan agent active" : `${String(agent).trim().toLowerCase()} agent active`
}

function terminalEvent(event) {
Expand Down Expand Up @@ -3956,6 +3985,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
cwd: pluginOptions.cwd || directory,
})
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents)
// Opt-out for deployments that deliberately drive execution from a planning
// agent. Defaults to false: unattended work must not escape Plan mode.
const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true

// The restricted agent currently driving this session, or "" when execution
// is permitted. Reads the execution context the host reports through
// `chat.message`, `chat.params`, and `session.updated`.
const restrictedAgentFor = (sessionID) => {
if (allowGoalExecutionFromPlan) return ""
const agent = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
return isRestrictedAgent(agent, restrictedAgents) ? String(agent).trim() : ""
}

// Record a newly created goal as held rather than active. Mirrors the idle
// guard's stop reason so `/goal status` reads the same either way.
const holdGoalForRestrictedAgent = (goal, agent) => {
const label = isPlanAgent(agent) ? "Plan" : agent
goal.stopped = true
goal.stopReason = restrictedAgentStopReason(agent)
goal.lastStatus =
`Goal recorded but held: the ${label} agent is planning-only. ` +
`Switch to an executing agent, then run /${commandName} resume to start work.`
pauseGoalClock(goal)
pushHistory(goal, "paused", `Created while the ${label} agent was active; held until an executing agent resumes it.`)
return label
}

// Each session owns an independent snapshot, ledger, write chain, and
// lifetime lease. A project can therefore host any number of unrelated goal
Expand Down Expand Up @@ -4481,12 +4537,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})

if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null

const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
if (isPlanAgent(currentContext?.agent)) {
const activeRestrictedAgent = restrictedAgentFor(sessionID)
if (activeRestrictedAgent) {
const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
await pauseActiveGoal(sessionID, {
stopReason: "plan agent active",
status: "Auto-continue paused because the active agent switched to Plan.",
history: "Paused before auto-continue because the active session agent switched to Plan.",
stopReason: restrictedAgentStopReason(activeRestrictedAgent),
status: `Auto-continue paused because the active agent switched to ${label}.`,
history: `Paused before auto-continue because the active session agent switched to ${label}.`,
})
return null
}
Expand Down Expand Up @@ -5240,6 +5297,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
)

// A goal set while a planning-only agent is active is recorded but held,
// so the objective and its budget survive the mode switch. Without this
// the goal is created live and the routed command text tells the model to
// start working; the idle guard only catches it on the *next* idle.
const creationRestrictedAgent = restrictedAgentFor(sessionID)
if (creationRestrictedAgent) {
holdGoalForRestrictedAgent(goal, creationRestrictedAgent)
}

// Replace the focused goal (cleanupGoal discards it); backgrounded goals
// for this session are preserved. Use `/goal add` to keep the current
// goal and add another. Clear any ordered-sequence flag so the new
Expand All @@ -5251,11 +5317,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
registerSessionGoal(goal)
focusGoal(sessionID, goal)
await persist(sessionID)
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
goal,
transition: replacedGoal ? "replaced-active" : "active",
expectedState: "active",
})
const heldLabel = creationRestrictedAgent
? isPlanAgent(creationRestrictedAgent)
? "Plan"
: creationRestrictedAgent
: ""
announceLifecycle(
sessionID,
heldLabel
? `Goal recorded but held while ${heldLabel} is active.`
: replacedGoal
? "Goal replaced and active."
: "Goal active.",
{
goal,
transition: heldLabel ? "paused" : replacedGoal ? "replaced-active" : "active",
expectedState: heldLabel ? "paused" : "active",
},
)
replaceCommandOutputText(
output,
[
Expand All @@ -5266,14 +5345,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
"",
]
: []),
`New active goal: ${goal.condition}`,
heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
"",
"Start working toward this goal now.",
"When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
"If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
// A held goal must not be told to start working. Command text reaches
// the model as a normal turn on current OpenCode builds, so this line
// would be the escape the plan guard exists to prevent.
...(heldLabel
? [
`The ${heldLabel} agent is planning-only, so this goal is not running.`,
"Do not begin work on it now. Continue planning only.",
`Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
]
: [
"Start working toward this goal now.",
"When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
"If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
]),
`Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
"",
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
Expand All @@ -5282,7 +5372,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
]
.filter((line) => line !== null)
.join("\n"),
{ preserveFiles: true, startsWork: true },
// A held goal is a control turn, not a work turn: `startsWork: false`
// routes it through the read-only command framing.
{ preserveFiles: true, startsWork: !heldLabel },
)
},

Expand Down Expand Up @@ -6607,6 +6699,9 @@ export const testInternals = {
isIdleEvent,
isPluginCommandMessage,
isPluginContinuationMessage,
isPlanAgent,
isRestrictedAgent,
normalizeRestrictedAgents,
isPluginGeneratedMessage,
legacyStateFilePaths,
messageHasToolCall,
Expand Down
Loading