diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fe320..2c9aa90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/README.md b/README.md index 5c3fee6..6f0f449 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 8675c96..ead8b93 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -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 @@ -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, { @@ -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) } diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 4364515..12d8c94 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -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") +})