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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Links:

The OpenCode Goal Plugin adds:

- `/goal <objective>` as an OpenCode command for TUI, desktop, and web.
- `/goal <objective>`, `/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.
Expand Down Expand Up @@ -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.

Expand All @@ -178,7 +178,7 @@ Use `/goal <objective>` 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 <objective>` 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 <objective>` 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.

Expand Down
145 changes: 121 additions & 24 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1285,10 +1291,60 @@ 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.

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 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.`;
}
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) {
Expand All @@ -1302,14 +1358,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")
Expand Down Expand Up @@ -2287,7 +2354,7 @@ var server = async ({ client }, options) => {
async config(config) {
if (!registerCommand)
return;
registerDesktopCommand(config, commandName);
registerDesktopCommands(config, commandName);
},
tool: {
get_goal: {
Expand Down Expand Up @@ -2383,6 +2450,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;
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);
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : undefined;
Expand Down Expand Up @@ -3070,23 +3151,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") {
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({
...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) => {
Expand Down
Loading