From 81cc6e07b0c4b311b99199e88d5a46f69bacf3e3 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Fri, 4 Sep 2026 12:18:32 +0200 Subject: [PATCH 1/2] feat: add pause and resume goal commands --- README.md | 10 +-- dist/server.js | 144 +++++++++++++++++++++++++++++------ src/server.ts | 139 ++++++++++++++++++++++++++++------ src/state.ts | 3 + test/server-v2.test.ts | 100 ++++++++++++++++++++++++- test/server.test.ts | 165 ++++++++++++++++++++++++++++++++++++++++- test/state.test.ts | 30 ++++++++ 7 files changed, 536 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 3dbf66e..84c7cf6 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Links: The OpenCode Goal Plugin adds: -- `/goal ` as an OpenCode command for TUI, desktop, and web. +- `/goal `, `/pause_goal`, and `/resume_goal` as OpenCode commands for TUI, desktop, web, and remote integrations that expose the server command catalog. - A sidebar goal indicator with status, elapsed time, and objective. -- Agent tools: `get_goal`, `get_goal_history`, `list_all_goals`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal`, and `clear_goal`. +- Agent tools: `get_goal`, `get_goal_history`, `list_all_goals`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal_status`, `update_goal`, and `clear_goal`. - Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker. - Persistent per-session goal state with history, checkpoints, budgets, and owner-only file permissions. - Optional automatic continuation on `session.idle` / `session.status`, with no-progress pause and budget wrap-up safeguards. @@ -165,8 +165,8 @@ Defaults: - `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit. - `no_progress_token_threshold`: `50`; output-token floor used to judge whether a goal continuation turn made progress. - `max_no_progress_turns`: `2`; consecutive low-progress goal continuation turns before pausing. Only turns produced by a reserved goal continuation count — ordinary low-output assistant messages (for example short tool-call-only turns from PTY or status checks) never increment this counter. -- `register_command`: `true` -- `command_name`: `"goal"` +- `register_command`: `true`; registers `/goal`, `/pause_goal`, and `/resume_goal`. +- `command_name`: `"goal"`; renames the main goal command only. The reserved names `pause_goal` and `resume_goal` fall back to `goal` so the standalone controls remain available. - `restricted_agents`: `["plan"]`; agents (matched case-insensitively) treated as planning-only for goal execution. - `allow_goal_execution_from_plan`: `false`; when `true`, disables Plan-mode goal restrictions entirely. @@ -178,7 +178,7 @@ Use `/goal ` in a fresh OpenCode chat to create a long-running goal: /goal review the frontend and translate visible English UI text to Spanish ``` -Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit ` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal. +Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit ` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. The standalone `/pause_goal` and `/resume_goal` controls are discoverable by remote integrations that expose OpenCode's server command catalog; their arguments and attachments are ignored so they cannot accidentally replace or influence the objective. `/pause_goal` persists the pause before its acknowledgement turn starts, preventing a later idle event from starting another continuation. It cannot cancel a continuation that was already delivered or whose delivery was already in flight when the pause was committed. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal. You can also ask the agent to formulate the objective and call `set_goal` itself, for example: "set your own goal to finish this refactor safely." The tool uses the agent-written objective but still only creates a goal when explicitly requested. diff --git a/dist/server.js b/dist/server.js index 680cf7f..21474f8 100644 --- a/dist/server.js +++ b/dist/server.js @@ -691,6 +691,12 @@ async function setGoalStatus(sessionID, status, agent) { const goal = state.goals[sessionID]; if (!goal) throw new Error("cannot update goal because this session has no goal"); + if (isClosed(goal.status)) + throw new Error("cannot update goal status because this goal is closed"); + if (goal.status === status) + return snapshot(goal); + if (status === "paused" && goal.status !== "active") + return snapshot(goal); accountWallClock(goal); goal.status = status; goal.updatedAt = nowSeconds(); @@ -1285,10 +1291,59 @@ Use the goal tools to handle this command: Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.`; } +function goalStatusCommandTemplate(commandName) { + if (commandName === "pause_goal") { + return `OpenCode goal mode command "/pause_goal" was invoked. + +Ignore any command arguments. Call get_goal first, then handle only this pause request: + +- If there is no goal, briefly report that no goal is set. +- If the goal is active, call update_goal_status with status "paused" and briefly report the result. +- If the goal is already paused, budgetLimited, or usageLimited, do not mutate it; briefly report that it is already stopped. +- If the goal is complete or unmet, do not mutate it; briefly report that it is closed. + +Do not create, resume, or continue a goal. Do not edit, clear, complete, or mark a goal unmet.`; + } + return `OpenCode goal mode command "/resume_goal" was invoked. + +Ignore any command arguments. Call get_goal first, then handle only this resume request: + +- If there is no goal, briefly report that no goal is set. +- If the goal is complete or unmet, do not mutate it; you must not reopen it. +- If the goal is already active, do not mutate it; continue working toward its existing objective. +- If the goal is paused, budgetLimited, or usageLimited, call update_goal_status with status "active", then continue working toward its existing objective. +- If Plan mode or another restricted agent prevents resuming, report that the user must switch to Build mode instead of retrying. + +Do not create, edit, clear, complete, or mark a goal unmet.`; +} +function goalCommandDefinitions(commandName) { + return [ + { + name: commandName, + description: "Set or view the long-running session goal", + template: goalCommandTemplate(commandName), + action: "goal" + }, + { + name: "pause_goal", + description: "Pause the current long-running session goal", + template: goalStatusCommandTemplate("pause_goal"), + action: "pause" + }, + { + name: "resume_goal", + description: "Resume the current long-running session goal", + template: goalStatusCommandTemplate("resume_goal"), + action: "resume" + } + ]; +} function commandNameFromOptions(options) { const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME; if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) return DEFAULT_COMMAND_NAME; + if (name.toLowerCase() === "pause_goal" || name.toLowerCase() === "resume_goal") + return DEFAULT_COMMAND_NAME; return name; } function positiveIntegerOrNull2(value) { @@ -1302,14 +1357,25 @@ function timeoutMillisecondsFromSeconds(value) { return null; return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS); } -function registerDesktopCommand(config, commandName) { +function registerDesktopCommands(config, commandName) { config.command ??= {}; - if (config.command[commandName]) - return; - config.command[commandName] = { - description: "Set or view the long-running session goal", - template: goalCommandTemplate(commandName) - }; + const commands = goalCommandDefinitions(commandName); + for (const command of commands) { + if (config.command[command.name]) + continue; + config.command[command.name] = { + description: command.description, + template: command.template + }; + } +} +function sanitizeGoalStatusCommandParts(output, template) { + const text = output.parts.find((part) => part.type === "text" && part.text?.startsWith(template)); + if (!text) + return false; + text.text = template; + output.parts.splice(0, output.parts.length, text); + return true; } function textFromPart(part) { if (!part || typeof part !== "object") @@ -2287,7 +2353,7 @@ var server = async ({ client }, options) => { async config(config) { if (!registerCommand) return; - registerDesktopCommand(config, commandName); + registerDesktopCommands(config, commandName); }, tool: { get_goal: { @@ -2383,6 +2449,20 @@ var server = async ({ client }, options) => { toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null); } }, + async "command.execute.before"(input, output) { + if (input.command !== "pause_goal" && input.command !== "resume_goal") + return; + const template = goalStatusCommandTemplate(input.command); + if (!sanitizeGoalStatusCommandParts(output, template)) + return; + if (input.command !== "pause_goal") + return; + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); + const goal = await getGoal(input.sessionID); + if (goal?.status === "active") + await setGoalStatus(input.sessionID, "paused"); + }, async "tool.execute.after"(input, output) { taskTracker.noteTaskOutput(input, output); const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined; @@ -3070,23 +3150,39 @@ async function setupV2(context) { } } if (registerCommand) { + const existingCommands = new Set((await context.command.list()).data.map((command) => command.name)); registrations.push(await context.command.transform((draft) => { - draft.add({ - name: commandName, - description: "Set or view the long-running session goal", - execute: async (input) => { - const stripMention = ({ mention: _mention, ...attachment }) => attachment; - await context.session.prompt({ - ...input.prompt, - files: input.prompt.files?.map(stripMention), - agents: input.prompt.agents?.map(stripMention), - skills: input.prompt.skills?.map(stripMention), - sessionID: input.sessionID, - text: goalCommandTemplate(commandName).replaceAll("$ARGUMENTS", () => input.prompt.text.trim()), - delivery: input.delivery - }); - } - }); + const claimedCommands = new Set(existingCommands); + for (const command of goalCommandDefinitions(commandName)) { + if (claimedCommands.has(command.name)) + continue; + claimedCommands.add(command.name); + draft.add({ + name: command.name, + description: command.description, + execute: async (input) => { + if (command.action === "pause") { + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); + const goal = await getGoal(input.sessionID); + if (goal?.status === "active") + await setGoalStatus(input.sessionID, "paused"); + } + const stripMention = ({ mention: _mention, ...attachment }) => attachment; + await context.session.prompt({ + ...command.action === "goal" ? { + ...input.prompt, + files: input.prompt.files?.map(stripMention), + agents: input.prompt.agents?.map(stripMention), + skills: input.prompt.skills?.map(stripMention) + } : {}, + sessionID: input.sessionID, + text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()), + delivery: input.delivery + }); + } + }); + } })); } registrations.push(await context.tool.transform((draft) => { diff --git a/src/server.ts b/src/server.ts index 16c1b5d..1f7e8a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -161,9 +161,67 @@ Use the goal tools to handle this command: Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds or returns an existing matching goal, never call it again for this command; continue working from the returned goal state.` } +function goalStatusCommandTemplate(commandName: "pause_goal" | "resume_goal") { + if (commandName === "pause_goal") { + return `OpenCode goal mode command "/pause_goal" was invoked. + +Ignore any command arguments. Call get_goal first, then handle only this pause request: + +- If there is no goal, briefly report that no goal is set. +- If the goal is active, call update_goal_status with status "paused" and briefly report the result. +- If the goal is already paused, budgetLimited, or usageLimited, do not mutate it; briefly report that it is already stopped. +- If the goal is complete or unmet, do not mutate it; briefly report that it is closed. + +Do not create, resume, or continue a goal. Do not edit, clear, complete, or mark a goal unmet.` + } + + return `OpenCode goal mode command "/resume_goal" was invoked. + +Ignore any command arguments. Call get_goal first, then handle only this resume request: + +- If there is no goal, briefly report that no goal is set. +- If the goal is complete or unmet, do not mutate it; you must not reopen it. +- If the goal is already active, do not mutate it; continue working toward its existing objective. +- If the goal is paused, budgetLimited, or usageLimited, call update_goal_status with status "active", then continue working toward its existing objective. +- If Plan mode or another restricted agent prevents resuming, report that the user must switch to Build mode instead of retrying. + +Do not create, edit, clear, complete, or mark a goal unmet.` +} + +type GoalCommandDefinition = { + name: string + description: string + template: string + action: "goal" | "pause" | "resume" +} + +function goalCommandDefinitions(commandName: string): GoalCommandDefinition[] { + return [ + { + name: commandName, + description: "Set or view the long-running session goal", + template: goalCommandTemplate(commandName), + action: "goal", + }, + { + name: "pause_goal", + description: "Pause the current long-running session goal", + template: goalStatusCommandTemplate("pause_goal"), + action: "pause", + }, + { + name: "resume_goal", + description: "Resume the current long-running session goal", + template: goalStatusCommandTemplate("resume_goal"), + action: "resume", + }, + ] +} + function commandNameFromOptions(options?: Options) { const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) return DEFAULT_COMMAND_NAME + if (name.toLowerCase() === "pause_goal" || name.toLowerCase() === "resume_goal") return DEFAULT_COMMAND_NAME return name } @@ -180,15 +238,26 @@ function timeoutMillisecondsFromSeconds(value: unknown) { return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS) } -function registerDesktopCommand(config: Config, commandName: string) { +function registerDesktopCommands(config: Config, commandName: string) { config.command ??= {} - if (config.command[commandName]) return - config.command[commandName] = { - description: "Set or view the long-running session goal", - template: goalCommandTemplate(commandName), + const commands = goalCommandDefinitions(commandName) + for (const command of commands) { + if (config.command[command.name]) continue + config.command[command.name] = { + description: command.description, + template: command.template, + } } } +function sanitizeGoalStatusCommandParts(output: { parts: Array<{ type: string; text?: string }> }, template: string) { + const text = output.parts.find((part) => part.type === "text" && part.text?.startsWith(template)) + if (!text) return false + text.text = template + output.parts.splice(0, output.parts.length, text) + return true +} + function textFromPart(part: unknown): string { if (!part || typeof part !== "object") return "" const value = part as Record @@ -1279,7 +1348,7 @@ const server: Plugin = async ({ client }, options?: Options) => { }, async config(config) { if (!registerCommand) return - registerDesktopCommand(config, commandName) + registerDesktopCommands(config, commandName) }, tool: { get_goal: { @@ -1391,6 +1460,16 @@ const server: Plugin = async ({ client }, options?: Options) => { toolAttempts.set(toolAttemptKey(sessionID, callID), goal?.pendingAttempt?.id ?? null) } }, + async "command.execute.before"(input, output) { + if (input.command !== "pause_goal" && input.command !== "resume_goal") return + const template = goalStatusCommandTemplate(input.command) + if (!sanitizeGoalStatusCommandParts(output, template)) return + if (input.command !== "pause_goal") return + cancelScheduledContinuation(input.sessionID) + clearTurnWatchdog(input.sessionID) + const goal = await getGoal(input.sessionID) + if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") + }, async "tool.execute.after"(input, output) { taskTracker.noteTaskOutput( input as { tool?: unknown; sessionID?: unknown; callID?: unknown }, @@ -2119,24 +2198,40 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise command.name)) registrations.push( await context.command.transform((draft) => { - draft.add({ - name: commandName, - description: "Set or view the long-running session goal", - execute: async (input) => { - const stripMention = ({ mention: _mention, ...attachment }: T) => attachment - await context.session.prompt({ - ...input.prompt, - files: input.prompt.files?.map(stripMention), - agents: input.prompt.agents?.map(stripMention), - skills: input.prompt.skills?.map(stripMention), - sessionID: input.sessionID, - text: goalCommandTemplate(commandName).replaceAll("$ARGUMENTS", () => input.prompt.text.trim()), - delivery: input.delivery, - }) - }, - }) + const claimedCommands = new Set(existingCommands) + for (const command of goalCommandDefinitions(commandName)) { + if (claimedCommands.has(command.name)) continue + claimedCommands.add(command.name) + draft.add({ + name: command.name, + description: command.description, + execute: async (input) => { + if (command.action === "pause") { + cancelScheduledContinuation(input.sessionID) + clearTurnWatchdog(input.sessionID) + const goal = await getGoal(input.sessionID) + if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") + } + const stripMention = ({ mention: _mention, ...attachment }: T) => attachment + await context.session.prompt({ + ...(command.action === "goal" + ? { + ...input.prompt, + files: input.prompt.files?.map(stripMention), + agents: input.prompt.agents?.map(stripMention), + skills: input.prompt.skills?.map(stripMention), + } + : {}), + sessionID: input.sessionID, + text: command.template.replaceAll("$ARGUMENTS", () => input.prompt.text.trim()), + delivery: input.delivery, + }) + }, + }) + } }), ) } diff --git a/src/state.ts b/src/state.ts index 0808e17..c1cd4dd 100644 --- a/src/state.ts +++ b/src/state.ts @@ -878,6 +878,9 @@ export async function setGoalStatus(sessionID: string, status: MutableGoalStatus return mutate((state) => { const goal = state.goals[sessionID] if (!goal) throw new Error("cannot update goal because this session has no goal") + if (isClosed(goal.status)) throw new Error("cannot update goal status because this goal is closed") + if (goal.status === status) return snapshot(goal) + if (status === "paused" && goal.status !== "active") return snapshot(goal) accountWallClock(goal) goal.status = status goal.updatedAt = nowSeconds() diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index ca528b8..2460168 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -92,6 +92,7 @@ type MockContext = { stream: ReturnType disposals: string[] command: { + list: () => Promise<{ data: Array<{ name: string }> }> transform: (callback: (draft: MockCommandDraft) => void) => Promise } tool: { @@ -110,7 +111,7 @@ type MockContext = { } } -function makeMockContext(options: Record = {}): MockContext { +function makeMockContext(options: Record = {}, existingCommands: string[] = []): MockContext { const tools: MockContext["tools"] = [] const commands: MockContext["commands"] = [] const hooks: MockContext["hooks"] = {} @@ -132,6 +133,7 @@ function makeMockContext(options: Record = {}): MockContext { stream, disposals, command: { + list: async () => ({ data: existingCommands.map((name) => ({ name })) }), transform: async (callback) => { callback({ add: (command) => commands.push(command) }) return registration("command.transform") @@ -321,11 +323,14 @@ test("V2 create_goal reuses the same active objective without reinitializing sta await cleanup() }) -test("V2 setup registers the /goal command via command transform", async () => { +test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transform", async () => { const mock = makeMockContext({ auto_continue: false }) const cleanup = await setupPlugin(mock as never) const command = mock.commands.find((candidate) => candidate.name === "goal") + const pause = mock.commands.find((candidate) => candidate.name === "pause_goal") + const resume = mock.commands.find((candidate) => candidate.name === "resume_goal") + expect(mock.commands.map((candidate) => candidate.name).sort()).toEqual(["goal", "pause_goal", "resume_goal"]) expect(command).toBeDefined() expect(command?.description).toBe("Set or view the long-running session goal") @@ -357,6 +362,97 @@ test("V2 setup registers the /goal command via command transform", async () => { expect(mock.promptCalls[1]).toMatchObject({ sessionID: "ses_empty", delivery: "steer" }) expect(mock.promptCalls[1]?.text).toContain("If the arguments are empty, call get_goal") + await pause?.execute({ + sessionID: "ses_pause", + prompt: { text: "ignored text", agents: [{ name: "build", mention: { start: 0, end: 7, text: "ignored" } }] }, + delivery: "steer", + }) + expect(mock.promptCalls[2]).toMatchObject({ + sessionID: "ses_pause", + delivery: "steer", + }) + expect(mock.promptCalls[2]?.agents).toBeUndefined() + expect(mock.promptCalls[2]?.text).toContain('command "/pause_goal" was invoked') + expect(mock.promptCalls[2]?.text).toContain('update_goal_status with status "paused"') + expect(mock.promptCalls[2]?.text).not.toContain("ignored text") + + await resume?.execute({ sessionID: "ses_resume", prompt: { text: "ignored" }, delivery: "queue" }) + expect(mock.promptCalls[3]).toMatchObject({ sessionID: "ses_resume", delivery: "queue" }) + expect(mock.promptCalls[3]?.text).toContain('command "/resume_goal" was invoked') + expect(mock.promptCalls[3]?.text).toContain('update_goal_status with status "active"') + expect(mock.promptCalls[3]?.text).toContain("Plan mode") + expect(mock.promptCalls[3]?.text).not.toContain("ignored") + + mock.stream.end() + await cleanup() +}) + +test("V2 setup preserves existing commands and configured command-name collisions", async () => { + const mock = makeMockContext({ auto_continue: false, command_name: "pause_goal" }, ["resume_goal"]) + const cleanup = await setupPlugin(mock as never) + + expect(mock.commands.map((command) => command.name)).toEqual(["goal", "pause_goal"]) + expect(mock.commands[0]?.description).toBe("Set or view the long-running session goal") + expect(mock.commands[1]?.description).toBe("Pause the current long-running session goal") + + mock.stream.end() + await cleanup() +}) + +test("V2 command transform remains stable when the registry replays it", async () => { + const mock = makeMockContext({ auto_continue: false }) + let transform: ((draft: MockCommandDraft) => void) | undefined + mock.command.transform = async (callback) => { + transform = callback + callback({ add: (command) => mock.commands.push(command) }) + return { + dispose: async () => { + mock.disposals.push("command.transform") + }, + } + } + const cleanup = await setupPlugin(mock as never) + expect(mock.commands.map((command) => command.name).sort()).toEqual(["goal", "pause_goal", "resume_goal"]) + + mock.commands.length = 0 + transform?.({ add: (command) => mock.commands.push(command) }) + + expect(mock.commands.map((command) => command.name).sort()).toEqual(["goal", "pause_goal", "resume_goal"]) + mock.stream.end() + await cleanup() +}) + +test("V2 pause_goal persists the pause before prompting and ignores attachments", async () => { + const mock = makeMockContext({ auto_continue: false }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "wait for remote guidance") + let statusAtPrompt: string | undefined + mock.session.prompt = async (input) => { + statusAtPrompt = (await getGoal(input.sessionID))?.status + mock.promptCalls.push(input) + return { id: "pending_pause" } + } + const pause = mock.commands.find((command) => command.name === "pause_goal") + + await pause?.execute({ + sessionID: "ses_v2", + prompt: { + text: "replace the goal", + files: [{ uri: "file:///tmp/untrusted.txt" }], + agents: [{ name: "plan" }], + skills: [{ id: "untrusted" }], + }, + delivery: "steer", + }) + + expect(statusAtPrompt).toBe("paused") + expect(await getGoal("ses_v2")).toMatchObject({ status: "paused", objective: "wait for remote guidance" }) + expect(mock.promptCalls[0]).toMatchObject({ sessionID: "ses_v2", delivery: "steer" }) + expect(mock.promptCalls[0]?.files).toBeUndefined() + expect(mock.promptCalls[0]?.agents).toBeUndefined() + expect(mock.promptCalls[0]?.skills).toBeUndefined() + expect(mock.promptCalls[0]?.text).not.toContain("replace the goal") + mock.stream.end() await cleanup() }) diff --git a/test/server.test.ts b/test/server.test.ts index ed5c667..9cdd360 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -244,7 +244,7 @@ test("duplicate limited goals retain the safety stop notice", async () => { expect(String(duplicate)).toContain("Safety limit reached") }) -test("server plugin registers goal as a desktop/web command by default", async () => { +test("server plugin registers goal, pause_goal, and resume_goal as desktop/web commands by default", async () => { const hooks = await setupServer( { client: { @@ -272,6 +272,17 @@ test("server plugin registers goal as a desktop/web command by default", async ( expect(config.command?.goal?.template).toContain("call get_goal first") expect(config.command?.goal?.template).toContain("call create_goal once") expect(config.command?.goal?.template).toContain("never call it again") + expect(config.command?.pause_goal?.description).toBe("Pause the current long-running session goal") + expect(config.command?.pause_goal?.template).toContain('command "/pause_goal" was invoked') + expect(config.command?.pause_goal?.template).toContain('update_goal_status with status "paused"') + expect(config.command?.pause_goal?.template).toContain("Do not create, resume, or continue") + expect(config.command?.pause_goal?.template).not.toContain("$ARGUMENTS") + expect(config.command?.resume_goal?.description).toBe("Resume the current long-running session goal") + expect(config.command?.resume_goal?.template).toContain('command "/resume_goal" was invoked') + expect(config.command?.resume_goal?.template).toContain('update_goal_status with status "active"') + expect(config.command?.resume_goal?.template).toContain("must not reopen it") + expect(config.command?.resume_goal?.template).toContain("Plan mode") + expect(config.command?.resume_goal?.template).not.toContain("$ARGUMENTS") }) test("system transform is byte-stable across the complete goal lifecycle", async () => { @@ -534,7 +545,7 @@ test("goal status tool pauses and resumes a goal", async () => { expect(String(resumed)).toContain('"lastStatus": "Goal resumed."') }) -test("server plugin does not overwrite an existing goal command", async () => { +test("server plugin does not overwrite existing goal commands", async () => { const hooks = await setupServer( { client: { @@ -551,6 +562,14 @@ test("server plugin does not overwrite an existing goal command", async () => { description: "custom", template: "custom template", }, + pause_goal: { + description: "custom pause", + template: "custom pause template", + }, + resume_goal: { + description: "custom resume", + template: "custom resume template", + }, }, } @@ -558,6 +577,148 @@ test("server plugin does not overwrite an existing goal command", async () => { expect(config.command.goal.description).toBe("custom") expect(config.command.goal.template).toBe("custom template") + expect(config.command.pause_goal.description).toBe("custom pause") + expect(config.command.pause_goal.template).toBe("custom pause template") + expect(config.command.resume_goal.description).toBe("custom resume") + expect(config.command.resume_goal.template).toBe("custom resume template") +}) + +test("a configured command-name collision preserves all standalone commands", async () => { + const hooks = await setupServer( + { client: { session: { promptAsync: async () => {} } } } as never, + { auto_continue: false, command_name: "pause_goal" }, + ) + const config = {} as { + command?: Record + } + + await hooks.config?.(config as never) + + expect(Object.keys(config.command ?? {}).sort()).toEqual(["goal", "pause_goal", "resume_goal"]) + expect(config.command?.goal?.description).toBe("Set or view the long-running session goal") + expect(config.command?.goal?.template).toContain('OpenCode goal mode command "/goal" was invoked') + expect(config.command?.pause_goal?.description).toBe("Pause the current long-running session goal") +}) + +test("pause_goal persists the pause before its acknowledgement turn", async () => { + const hooks = await setupServer( + { client: { session: { promptAsync: async () => {} } } } as never, + { auto_continue: false }, + ) + const config = {} as { command?: Record } + await hooks.config?.(config as never) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "wait for remote guidance" }, + { sessionID: "ses_pause", agent: "build" } as never, + ) + + const output = { + parts: [ + { + id: "part_text", + sessionID: "ses_pause", + messageID: "msg_pause", + type: "text", + text: `${config.command?.pause_goal?.template}\nuntrusted arguments`, + }, + { + id: "part_file", + sessionID: "ses_pause", + messageID: "msg_pause", + type: "file", + mime: "text/plain", + url: "file:///tmp/untrusted.txt", + }, + ], + } + await hooks["command.execute.before"]?.( + { command: "pause_goal", sessionID: "ses_pause", arguments: "ignored" }, + output as never, + ) + + expect((await getGoal("ses_pause"))?.status).toBe("paused") + expect(output.parts).toHaveLength(1) + expect(output.parts[0]?.text).toBe(config.command?.pause_goal?.template) +}) + +test("an existing pause_goal command is not intercepted", async () => { + const hooks = await setupServer( + { client: { session: { promptAsync: async () => {} } } } as never, + { auto_continue: false }, + ) + await hooks.config?.({ command: { pause_goal: { template: "custom" } } } as never) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "keep running" }, + { sessionID: "ses_custom_pause", agent: "build" } as never, + ) + + await hooks["command.execute.before"]?.( + { command: "pause_goal", sessionID: "ses_custom_pause", arguments: "" }, + { + parts: [ + { + id: "part_custom", + sessionID: "ses_custom_pause", + messageID: "msg_custom", + type: "text", + text: "custom", + }, + ], + } as never, + ) + + expect((await getGoal("ses_custom_pause"))?.status).toBe("active") +}) + +test("resume_goal strips rendered arguments and attachments without bypassing the status tool", async () => { + const hooks = await setupServer( + { client: { session: { promptAsync: async () => {} } } } as never, + { auto_continue: false }, + ) + const config = {} as { command?: Record } + await hooks.config?.(config as never) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "resume only through the tool" }, + { sessionID: "ses_resume", agent: "build" } as never, + ) + await requireTool(tools.update_goal_status, "update_goal_status").execute( + { status: "paused" }, + { sessionID: "ses_resume", agent: "build" } as never, + ) + const output = { + parts: [ + { + id: "part_resume", + sessionID: "ses_resume", + messageID: "msg_resume", + type: "text", + text: `${config.command?.resume_goal?.template}\nuntrusted arguments`, + }, + { + id: "part_resume_file", + sessionID: "ses_resume", + messageID: "msg_resume", + type: "file", + mime: "text/plain", + url: "file:///tmp/untrusted.txt", + }, + ], + } + + await hooks["command.execute.before"]?.( + { command: "resume_goal", sessionID: "ses_resume", arguments: "ignored" }, + output as never, + ) + + expect((await getGoal("ses_resume"))?.status).toBe("paused") + expect(output.parts).toHaveLength(1) + expect(output.parts[0]?.text).toBe(config.command?.resume_goal?.template) }) test("server plugin can disable desktop/web command registration", async () => { diff --git a/test/state.test.ts b/test/state.test.ts index 0f8c4cc..da2bbd1 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -55,6 +55,36 @@ test("creates, reads, pauses, resumes, completes, and clears a goal", async () = expect(await getGoal("ses_1")).toBeNull() }) +test("status transitions are idempotent and cannot reopen closed goals", async () => { + await createGoal("ses_1", "ship safely", null) + const active = await getGoal("ses_1") + await setGoalStatus("ses_1", "active") + expect((await getGoal("ses_1"))?.history).toEqual(active?.history) + + await setGoalStatus("ses_1", "paused") + const paused = await getGoal("ses_1") + await setGoalStatus("ses_1", "paused") + expect((await getGoal("ses_1"))?.history).toEqual(paused?.history) + + await completeGoal("ses_1", "verified") + await expect(setGoalStatus("ses_1", "active")).rejects.toThrow("goal is closed") + expect((await getGoal("ses_1"))?.status).toBe("complete") +}) + +test("pausing an already limited goal preserves its safety status", async () => { + await createGoal("ses_limited", "stay bounded", 1) + await accountUsage("ses_limited", 2) + const limited = await getGoal("ses_limited") + + await setGoalStatus("ses_limited", "paused") + + expect(await getGoal("ses_limited")).toMatchObject({ + status: "budgetLimited", + lastStatus: limited?.lastStatus, + history: limited?.history, + }) +}) + test("a mutation writes back to the state path it read", async () => { const firstPath = process.env.OPENCODE_GOAL_STATE_PATH! const secondPath = join(dir, "other-goals.json") From 5ac71e4c126a295c6ff9f9e4a10e3cf354e481d4 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Fri, 4 Sep 2026 12:29:25 +0200 Subject: [PATCH 2/2] fix: harden goal control command handling --- README.md | 2 +- dist/server.js | 15 ++++++++------- src/server.ts | 15 ++++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 84c7cf6..b8ffffe 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Use `/goal ` in a fresh OpenCode chat to create a long-running goal: /goal review the frontend and translate visible English UI text to Spanish ``` -Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit ` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. The standalone `/pause_goal` and `/resume_goal` controls are discoverable by remote integrations that expose OpenCode's server command catalog; their arguments and attachments are ignored so they cannot accidentally replace or influence the objective. `/pause_goal` persists the pause before its acknowledgement turn starts, preventing a later idle event from starting another continuation. It cannot cancel a continuation that was already delivered or whose delivery was already in flight when the pause was committed. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal. +Bare `/goal` reports the current goal state. `/goal history` reports lifecycle history and recent checkpoints. `/goal edit ` updates the current objective. `/goal pause` pauses the goal without clearing it, and `/goal resume` resumes it. The standalone `/pause_goal` and `/resume_goal` controls are discoverable by remote integrations that expose OpenCode's server command catalog. Their arguments and resolved attachments are removed before composing the goal-control prompt, although OpenCode V1 may evaluate its own command syntax before plugin hooks run. `/pause_goal` persists the pause before its acknowledgement turn starts, preventing a later idle event from starting another continuation. It cannot cancel a continuation that was already delivered or whose delivery was already in flight when the pause was committed. Pausing a goal that is already `budgetLimited` or `usageLimited` preserves that safety status; resuming a closed `complete` or `unmet` goal is rejected. `/goal clear` clears the goal; `/goal stop`, `/goal off`, `/goal reset`, `/goal none`, and `/goal cancel` are clear aliases. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, pausing, resuming, showing history, or clearing the current goal state without creating a new goal. You can also ask the agent to formulate the objective and call `set_goal` itself, for example: "set your own goal to finish this refactor safely." The tool uses the agent-written objective but still only creates a goal when explicitly requested. diff --git a/dist/server.js b/dist/server.js index 21474f8..2c453e6 100644 --- a/dist/server.js +++ b/dist/server.js @@ -1295,11 +1295,12 @@ function goalStatusCommandTemplate(commandName) { if (commandName === "pause_goal") { return `OpenCode goal mode command "/pause_goal" was invoked. -Ignore any command arguments. Call get_goal first, then handle only this pause request: +The command handler pauses an active goal before this acknowledgement turn when possible. Ignore any command arguments, call get_goal first, then handle only this pause request: - If there is no goal, briefly report that no goal is set. -- If the goal is active, call update_goal_status with status "paused" and briefly report the result. -- If the goal is already paused, budgetLimited, or usageLimited, do not mutate it; briefly report that it is already stopped. +- If the goal is paused, do not mutate it again; briefly confirm "Goal paused." +- If the goal is still active, call update_goal_status with status "paused" and briefly report the result. +- If the goal is budgetLimited or usageLimited, do not mutate it; briefly report that it remains stopped by its safety limit. - If the goal is complete or unmet, do not mutate it; briefly report that it is closed. Do not create, resume, or continue a goal. Do not edit, clear, complete, or mark a goal unmet.`; @@ -2457,11 +2458,11 @@ var server = async ({ client }, options) => { return; if (input.command !== "pause_goal") return; - cancelScheduledContinuation(input.sessionID); - clearTurnWatchdog(input.sessionID); const goal = await getGoal(input.sessionID); if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused"); + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); }, async "tool.execute.after"(input, output) { taskTracker.noteTaskOutput(input, output); @@ -3162,11 +3163,11 @@ async function setupV2(context) { description: command.description, execute: async (input) => { if (command.action === "pause") { - cancelScheduledContinuation(input.sessionID); - clearTurnWatchdog(input.sessionID); const goal = await getGoal(input.sessionID); if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused"); + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); } const stripMention = ({ mention: _mention, ...attachment }) => attachment; await context.session.prompt({ diff --git a/src/server.ts b/src/server.ts index 1f7e8a0..b56ee3f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -165,11 +165,12 @@ function goalStatusCommandTemplate(commandName: "pause_goal" | "resume_goal") { if (commandName === "pause_goal") { return `OpenCode goal mode command "/pause_goal" was invoked. -Ignore any command arguments. Call get_goal first, then handle only this pause request: +The command handler pauses an active goal before this acknowledgement turn when possible. Ignore any command arguments, call get_goal first, then handle only this pause request: - If there is no goal, briefly report that no goal is set. -- If the goal is active, call update_goal_status with status "paused" and briefly report the result. -- If the goal is already paused, budgetLimited, or usageLimited, do not mutate it; briefly report that it is already stopped. +- If the goal is paused, do not mutate it again; briefly confirm "Goal paused." +- If the goal is still active, call update_goal_status with status "paused" and briefly report the result. +- If the goal is budgetLimited or usageLimited, do not mutate it; briefly report that it remains stopped by its safety limit. - If the goal is complete or unmet, do not mutate it; briefly report that it is closed. Do not create, resume, or continue a goal. Do not edit, clear, complete, or mark a goal unmet.` @@ -1465,10 +1466,10 @@ const server: Plugin = async ({ client }, options?: Options) => { const template = goalStatusCommandTemplate(input.command) if (!sanitizeGoalStatusCommandParts(output, template)) return if (input.command !== "pause_goal") return - cancelScheduledContinuation(input.sessionID) - clearTurnWatchdog(input.sessionID) const goal = await getGoal(input.sessionID) if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") + cancelScheduledContinuation(input.sessionID) + clearTurnWatchdog(input.sessionID) }, async "tool.execute.after"(input, output) { taskTracker.noteTaskOutput( @@ -2210,10 +2211,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { if (command.action === "pause") { - cancelScheduledContinuation(input.sessionID) - clearTurnWatchdog(input.sessionID) const goal = await getGoal(input.sessionID) if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") + cancelScheduledContinuation(input.sessionID) + clearTurnWatchdog(input.sessionID) } const stripMention = ({ mention: _mention, ...attachment }: T) => attachment await context.session.prompt({