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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Document OpenCode 2 status explicitly: unsupported and untested, with the existing dual-shape session adapter noted and a concrete checklist of what a supported claim would require. Records that this plugin is server-only, so its configuration lives entirely in `opencode.json` on any OpenCode line.
- Add an opt-in session-title status indicator (`sessionTitleStatus`), mirroring live goal status — state icon, objective, turns, elapsed, and tokens — into the OpenCode session title so unattended runs show a continuous heartbeat. The original title is captured before the first overwrite and restored by `/goal clear`; unchanged renders skip the API call, and update failures are logged at debug level without interrupting the goal loop. The indicator refreshes on commands and on idle/compaction/interruption events but never on the `message.updated` events that stream during a turn, keeping API round-trips out of the response path. Status lines written by a previous process are recognized as the plugin's own, so a restart followed by `/goal clear` cannot promote stale goal status to the session's permanent title.
- 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.
- Resolve the active agent from the session record when the host's execution context is not yet populated. `command.execute.before` runs before `chat.message`/`chat.params`, so the context was empty for the first command in a session and the planning-only restriction failed open exactly where it mattered — a freshly opened Plan-mode session. Verified against a live OpenCode 1.18.25 TUI: a goal set under Plan now records `stopped: true`, `stopReason: plan agent active`, and sends zero auto-continues, where previously it started and ran the loop.
- 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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,10 @@ A planning-only agent is never driven into execution by the goal loop. OpenCode'
- 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.

The active agent is read from the execution context the host reports, falling back to the session record. That fallback matters: OpenCode runs `command.execute.before` before any `chat.message`/`chat.params` for the turn, so the context is empty for the first command in a session — the exact case a freshly opened Plan-mode session hits.

**What this does and does not prevent.** The restriction stops the *goal loop*: a held goal sends zero auto-continues, so no unattended work happens. It cannot stop a model from acting on the single routed command turn, because OpenCode's `command.execute.before` does not fully intercept command text (see [Limitations](#limitations)). A held goal's routed text explicitly tells the model not to begin work and is sent as a read-only control turn, but a non-compliant model may still act on that one turn. Verified against OpenCode 1.18.25: a goal set under Plan records `stopped: true`, `stopReason: plan agent active`, and `turnCount: 0`.

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

| Option | Default | Controls |
Expand Down
38 changes: 33 additions & 5 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4127,10 +4127,38 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
// 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) => {
// Resolve the agent driving a session, preferring the execution context the
// host reports through `chat.message` / `chat.params` / `session.updated`.
//
// That context is empty for the first command in a session: OpenCode runs
// `command.execute.before` before any of those signals fire. Relying on it
// alone made the restriction fail open exactly where it matters most — a
// freshly opened session in Plan mode — so fall back to the session record,
// which carries the selected agent from the moment the user picks it.
const resolveSessionAgent = async (sessionID) => {
if (!sessionID) return ""
const cached = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
if (typeof cached === "string" && cached.trim()) return cached.trim()
try {
const session = await sessionApi.get(sessionID)
const agent = typeof session?.agent === "string" ? session.agent.trim() : ""
// Remember it so later hooks in the same turn do not re-fetch. `replace`
// is intentionally false: this must not clobber a richer context (model,
// variant) that a host signal may already have recorded.
if (agent) rememberSessionExecutionContext(sessionID, { agent })
return agent
} catch (error) {
// Hosts that do not expose the agent fail open, matching the behavior
// before the restriction existed.
await logPluginDebug(client, "Failed to resolve the session agent", error)
return ""
}
}

const restrictedAgentFor = async (sessionID) => {
if (allowGoalExecutionFromPlan) return ""
const agent = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
return isRestrictedAgent(agent, restrictedAgents) ? String(agent).trim() : ""
const agent = await resolveSessionAgent(sessionID)
return isRestrictedAgent(agent, restrictedAgents) ? agent : ""
}

// Record a newly created goal as held rather than active. Mirrors the idle
Expand Down Expand Up @@ -4671,7 +4699,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})

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

const activeRestrictedAgent = restrictedAgentFor(sessionID)
const activeRestrictedAgent = await restrictedAgentFor(sessionID)
if (activeRestrictedAgent) {
const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
await pauseActiveGoal(sessionID, {
Expand Down Expand Up @@ -5437,7 +5465,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
// 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)
const creationRestrictedAgent = await restrictedAgentFor(sessionID)
if (creationRestrictedAgent) {
holdGoalForRestrictedAgent(goal, creationRestrictedAgent)
}
Expand Down
88 changes: 88 additions & 0 deletions test/goal-plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10086,3 +10086,91 @@ test("a failing session-title update never breaks the goal loop", async () => {
assert.equal(currentGoal("session-1").condition, "ship it", "the goal must still be created")
assert.ok(output.parts.length > 0, "the command must still produce output")
})

test("a goal set in Plan mode is held even when no execution context exists yet", async () => {
// Reproduces the live-canary failure on OpenCode 1.18: the TUI runs
// `command.execute.before` before any chat.message/chat.params fires, so the
// cached execution context is empty for the first command in a session. The
// guard must fall back to the session record rather than failing open.
const calls = []
const client = {
app: { log: async () => {} },
session: {
messages: async () => ({ data: [] }),
promptAsync: async (input) => {
calls.push(input)
return {}
},
abort: async () => ({}),
// The ONLY place the agent is visible — exactly the live situation.
get: async () => ({ data: { id: "session-1", agent: "plan", title: "t" } }),
},
}
const hooks = await GoalPlugin({ client }, { persistState: false, minDelayMs: 1 })

const output = { parts: [] }
await hooks["command.execute.before"](
{ command: "goal", sessionID: "session-1", arguments: "make it print goodbye" },
output,
)

const goal = currentGoal("session-1")
assert.equal(goal.stopped, true, "must be held despite an empty execution context")
assert.equal(goal.stopReason, "plan agent active")
const text = output.parts.map((part) => part.text).join("\n")
assert.ok(!text.includes("Start working toward this goal now."), "must not instruct the model to start")

await hooks.event({
event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } },
})
assert.equal(calls.length, 0, "a held goal must never auto-continue")
})

test("session-agent lookup failure fails open rather than blocking every goal", async () => {
// A host that does not expose the agent must not have every goal held.
const client = {
app: { log: async () => {} },
session: {
messages: async () => ({ data: [] }),
promptAsync: async () => ({}),
abort: async () => ({}),
get: async () => {
throw new Error("host does not support session.get")
},
},
}
const hooks = await GoalPlugin({ client }, { persistState: false, minDelayMs: 1 })
await hooks["command.execute.before"](
{ command: "goal", sessionID: "session-1", arguments: "ship it" },
{ parts: [] },
)
assert.equal(currentGoal("session-1").stopped, false, "must fail open when the agent is unknowable")
})

test("a cached execution context is preferred over refetching the session", async () => {
let getCalls = 0
const client = {
app: { log: async () => {} },
session: {
messages: async () => ({ data: [] }),
promptAsync: async () => ({}),
abort: async () => ({}),
get: async () => {
getCalls += 1
return { data: { id: "session-1", agent: "plan" } }
},
},
}
const hooks = await GoalPlugin({ client }, { persistState: false, minDelayMs: 1 })
// Host reports "build" through the normal signal path first.
await hooks["chat.message"](
{ sessionID: "session-1", agent: "build" },
{ message: { id: "m1", role: "user", sessionID: "session-1", agent: "build" }, parts: [textPart("go")] },
)
await hooks["command.execute.before"](
{ command: "goal", sessionID: "session-1", arguments: "ship it" },
{ parts: [] },
)
assert.equal(currentGoal("session-1").stopped, false, "the cached build context must win")
assert.equal(getCalls, 0, "a known agent must not cost a session fetch")
})