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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,7 @@ The goal sidebar shows the current status, elapsed time, token usage, auto-conti

Cancelling an active goal from an ACP client is durable when OpenCode emits the user-cancellation events described above. The plugin prevents subsequent goal continuations, including callbacks still preparing a prompt when cancellation arrives. OpenCode owns cancellation of in-flight model requests, tools, subprocesses, and already submitted prompts; the plugin cannot guarantee process termination if the host does not abort them or does not publish a cancellation signal. It does not interpret ordinary idle events, provider-error text, or completed tool calls as user cancellation.

Goal lifetime and ACP prompt-turn lifetime are separate. The plugin continues an active goal across successful executions, but does not control the ACP adapter's response to `session/prompt`. A host that returns `end_turn` after the initial execution can therefore show an idle client while subsequent goal work runs. In an isolated OpenCode 2.0.21 ACP test, registered `/goal` commands returned `end_turn` while their submitted execution was still running; a normal `session/prompt` remained open and returned `cancelled` after Cancel. Keeping that ACP request open across goal continuations, and exposing structured goal plans as ACP `plan` updates, requires host integration. Request or phase completion alone must not mark a goal complete. Long-running goals remain supported within the configured budgets; this cancellation handling adds no timeout.
Registered `/goal <objective>` and `/resume_goal` commands use the host's public session-wait API to remain pending across automatic continuations until the goal stops, completes, reaches a safety limit, or auto-continuation is disabled. Hosts without that API keep their previous admission behavior. The ACP adapter must also stream each execution owned by the command: an adapter that ends its event consumer after the first execution can still hide later work. Ordinary prompts retain their host-defined turn lifetime.


The Promise-based persistence layer uses Effect 3 under the private `effect-goal-state` npm alias. This keeps it separate from modern OpenCode's shared Effect 4 SDK runtime. It remains an external runtime dependency, and no private Effect values cross the plugin API.
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 63 additions & 8 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod";
import { randomUUID as randomUUID2 } from "crypto";
import { mkdir, readFile } from "fs/promises";
import { dirname as dirname2 } from "path";
import { Data, Effect, Schema } from "effect";
import { Data, Effect, Schema } from "effect-goal-state";

// src/atomic-write.ts
import { randomUUID } from "crypto";
Expand Down Expand Up @@ -3744,6 +3744,7 @@ async function setupV2(context) {
};
const registrations = [];
let disposed = false;
let eventConsumerStopped = false;
function stepKey(sessionID, messageID2) {
return `${sessionID}\x00${messageID2}`;
}
Expand Down Expand Up @@ -4391,7 +4392,7 @@ async function setupV2(context) {
draft.add({
name: command.name,
description: command.description,
execute: async (input) => {
execute: async (input, execution) => {
markSessionOwnership(input.sessionID, true);
if (command.action === "pause") {
const goal = await getGoal(input.sessionID);
Expand All @@ -4414,12 +4415,63 @@ async function setupV2(context) {
...skills ? { skills: skills.map(stripMention) } : {}
};
}
await context.session.prompt({
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText2(input.prompt.text.trim())),
delivery: input.delivery
});
const args = input.prompt.text.trim().toLowerCase();
const controlOnly = /^(history|status|show|current|pause|stop|cancel|clear|off|reset|none|edit)(?:\s|$)/.test(args);
const pursue = command.action === "resume" || command.action === "goal" && args !== "" && !controlOnly;
const signal = execution?.signal ? AbortSignal.any([abortController.signal, execution.signal]) : abortController.signal;
let admitted = false;
let cancellation;
const cancel = () => {
if (!pursue || disposed)
return;
goalServices.stopAutonomy?.(input.sessionID);
cancellation = cancelActiveGoal(input.sessionID).catch((error) => v2ErrorLog("Failed to persist command cancellation", error));
};
execution?.signal?.addEventListener("abort", cancel, { once: true });
try {
if (execution?.signal?.aborted) {
cancel();
return;
}
await context.session.prompt({
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText2(input.prompt.text.trim())),
delivery: input.delivery
});
admitted = true;
if (pursue && typeof context.session.wait === "function") {
let pursuedGoalID;
do {
await context.session.wait({ sessionID: input.sessionID }, { signal });
if (eventConsumerStopped)
throw new Error("goal event stream stopped");
const goal = await getGoal(input.sessionID);
if (signal.aborted || disposed || stoppedExecutions.has(input.sessionID) || !autoContinue || goal?.status !== "active")
break;
pursuedGoalID ??= goal.id;
if (goal.id !== pursuedGoalID)
break;
await new Promise((resolve) => setTimeout(resolve, 250));
} while (!disposed && !signal.aborted);
}
} catch (error) {
if (!admitted && !disposed && !execution?.signal?.aborted)
throw error;
if (!disposed && !execution?.signal?.aborted) {
goalServices.stopAutonomy?.(input.sessionID);
v2ErrorLog("Goal command wait failed; autonomous continuation stopped", error);
try {
if ((await getGoal(input.sessionID))?.status === "active")
await setGoalStatus(input.sessionID, "paused");
} catch (stateError) {
v2ErrorLog("Failed to pause goal after command failure", stateError);
}
}
} finally {
execution?.signal?.removeEventListener("abort", cancel);
await cancellation;
}
}
});
}
Expand Down Expand Up @@ -4552,6 +4604,9 @@ async function setupV2(context) {
} catch (error) {
if (!abortController.signal.aborted)
v2ErrorLog("V2 event consumer stopped", error);
} finally {
if (!disposed && !abortController.signal.aborted)
eventConsumerStopped = true;
}
})();
return async () => {
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
],
"scripts": {
"clean": "rm -rf dist",
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external effect --external zod",
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external effect-goal-state --external zod",
"ci:version": "bun scripts/resolve-ci-version.ts",
"lint": "eslint .",
"pack:dry-run": "npm pack --dry-run",
Expand All @@ -58,8 +58,8 @@
},
"dependencies": {
"@opencode-ai/plugin": "^1.17.1",
"effect": "^3.21.2",
"zod": "^4.1.8"
"zod": "^4.1.8",
"effect-goal-state": "npm:effect@^3.21.2"
},
"peerDependencies": {
"@opentui/solid": ">=0.5.8",
Expand Down
75 changes: 68 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2211,6 +2211,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
}
const registrations: Array<{ dispose(): Promise<void> }> = []
let disposed = false
let eventConsumerStopped = false

function stepKey(sessionID: string, messageID: string) {
return `${sessionID}\0${messageID}`
Expand Down Expand Up @@ -2935,7 +2936,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
draft.add({
name: command.name,
description: command.description,
execute: async (input) => {
execute: async (input, execution?: { signal?: AbortSignal }) => {
// Command execution is routed to the session's owning location.
markSessionOwnership(input.sessionID, true)
if (command.action === "pause") {
Expand All @@ -2961,12 +2962,70 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
...(skills ? { skills: skills.map(stripMention) } : {}),
}
}
await context.session.prompt({
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText(input.prompt.text.trim())),
delivery: input.delivery,
})
const args = input.prompt.text.trim().toLowerCase()
const controlOnly = /^(history|status|show|current|pause|stop|cancel|clear|off|reset|none|edit)(?:\s|$)/.test(args)
const pursue = command.action === "resume" || (command.action === "goal" && args !== "" && !controlOnly)
const signal = execution?.signal
? AbortSignal.any([abortController.signal, execution.signal])
: abortController.signal
let admitted = false
let cancellation: Promise<unknown> | undefined
const cancel = () => {
if (!pursue || disposed) return
goalServices.stopAutonomy?.(input.sessionID)
cancellation = cancelActiveGoal(input.sessionID).catch((error) =>
v2ErrorLog("Failed to persist command cancellation", error),
)
}
execution?.signal?.addEventListener("abort", cancel, { once: true })
try {
if (execution?.signal?.aborted) {
cancel()
return
}
await context.session.prompt({
...forwardedPrompt,
sessionID: input.sessionID,
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText(input.prompt.text.trim())),
delivery: input.delivery,
})
admitted = true
// A goal command owns all its automatic execution cycles.
// Older hosts without wait retain admission-only behavior.
if (pursue && typeof context.session.wait === "function") {
let pursuedGoalID: string | undefined
do {
await context.session.wait({ sessionID: input.sessionID }, { signal })
if (eventConsumerStopped) throw new Error("goal event stream stopped")
const goal = await getGoal(input.sessionID)
if (
signal.aborted ||
disposed ||
stoppedExecutions.has(input.sessionID) ||
!autoContinue ||
goal?.status !== "active"
)
break
pursuedGoalID ??= goal.id
if (goal.id !== pursuedGoalID) break
await new Promise((resolve) => setTimeout(resolve, 250))
} while (!disposed && !signal.aborted)
}
} catch (error) {
if (!admitted && !disposed && !execution?.signal?.aborted) throw error
if (!disposed && !execution?.signal?.aborted) {
goalServices.stopAutonomy?.(input.sessionID)
v2ErrorLog("Goal command wait failed; autonomous continuation stopped", error)
try {
if ((await getGoal(input.sessionID))?.status === "active") await setGoalStatus(input.sessionID, "paused")
} catch (stateError) {
v2ErrorLog("Failed to pause goal after command failure", stateError)
}
}
} finally {
execution?.signal?.removeEventListener("abort", cancel)
await cancellation
}
},
})
}
Expand Down Expand Up @@ -3139,6 +3198,8 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
}
} catch (error) {
if (!abortController.signal.aborted) v2ErrorLog("V2 event consumer stopped", error)
} finally {
if (!disposed && !abortController.signal.aborted) eventConsumerStopped = true
}
})()

Expand Down
2 changes: 1 addition & 1 deletion src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"
import { readFileSync } from "node:fs"
import { mkdir, readFile } from "node:fs/promises"
import { dirname } from "node:path"
import { Data, Effect, Schema } from "effect"
import { Data, Effect, Schema } from "effect-goal-state"
import { atomicWriteFile } from "./atomic-write"
import { statePath } from "./state-path"

Expand Down
Loading
Loading