diff --git a/README.md b/README.md index 9fd333d..56235bd 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/bun.lock b/bun.lock index c3bc065..a265303 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "opencode-goal-plugin", "dependencies": { "@opencode-ai/plugin": "^1.17.1", - "effect": "^3.21.2", + "effect-goal-state": "npm:effect@^3.21.2", "zod": "^4.1.8", }, "devDependencies": { @@ -486,6 +486,8 @@ "effect": ["effect@3.21.2", "https://registry.npmjs.org/effect/-/effect-3.21.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + "effect-goal-state": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.349", "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="], "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], diff --git a/dist/server.js b/dist/server.js index 80e08f7..9c70d6e 100644 --- a/dist/server.js +++ b/dist/server.js @@ -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"; @@ -3744,6 +3744,7 @@ async function setupV2(context) { }; const registrations = []; let disposed = false; + let eventConsumerStopped = false; function stepKey(sessionID, messageID2) { return `${sessionID}\x00${messageID2}`; } @@ -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); @@ -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; + } } }); } @@ -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 () => { diff --git a/package.json b/package.json index 70364c0..4876bda 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/server.ts b/src/server.ts index 9843fcc..324af44 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2211,6 +2211,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise }> = [] let disposed = false + let eventConsumerStopped = false function stepKey(sessionID: string, messageID: string) { return `${sessionID}\0${messageID}` @@ -2935,7 +2936,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { + execute: async (input, execution?: { signal?: AbortSignal }) => { // Command execution is routed to the session's owning location. markSessionOwnership(input.sessionID, true) if (command.action === "pause") { @@ -2961,12 +2962,70 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise 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 | 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 + } }, }) } @@ -3139,6 +3198,8 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise Promise + }, execution?: { signal?: AbortSignal }) => Promise }): void } @@ -485,6 +485,142 @@ test("V2 create_goal reuses the same active objective without reinitializing sta await cleanup() }) +test("V2 /goal waits across execution cycles and resolves when the overall goal closes", async () => { + const mock = makeMockContext() + let waits = 0 + let finish: (() => void) | undefined + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + waits++ + if (waits > 1) + await new Promise((resolve) => { + finish = resolve + }) + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "finish the entire goal") + let returned = false + const command = mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "finish the entire goal" }, delivery: "steer" }) + .then(() => { + returned = true + }) + await waitFor(() => waits === 2) + expect(returned).toBe(false) + await completeGoal("ses_v2", "All requested deliverables were verified") + finish?.() + await command + expect(returned).toBe(true) +}) + +test("V2 cancellation releases a waiting goal command without restarting the goal", async () => { + const mock = makeMockContext() + let finish: (() => void) | undefined + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => + new Promise((resolve) => { + finish = resolve + }), + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "respect Cancel during a long-running command") + const command = mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "respect Cancel" }, delivery: "steer" }) + await waitFor(() => finish !== undefined) + await mock.stream.push({ + type: "session.execution.interrupted", + created: 1, + data: { sessionID: "ses_v2", reason: "user" }, + }) + finish?.() + await command + expect((await getGoal("ses_v2"))?.status).toBe("cancelled") + expect(mock.promptCalls).toHaveLength(1) +}) + +test("V2 disposal aborts a pending command wait", async () => { + const mock = makeMockContext() + let waiting = false + const context = { + ...mock, + session: { + ...mock.session, + wait: async (_input: unknown, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + waiting = true + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + }), + }, + } + const cleanup = await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "stop when the plugin is disposed") + const command = mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "Verify cleanup on disposal" }, delivery: "steer" }) + await waitFor(() => waiting) + await cleanup() + await command + expect((await getGoal("ses_v2"))?.status).toBe("active") +}) + +test("V2 command wait ends if the pursued goal is replaced", async () => { + const mock = makeMockContext() + let waits = 0 + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + waits++ + if (waits === 2) await goalTool(mock, "replace_goal").execute({ objective: "A separate goal" }, toolContext()) + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Original goal") + await mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "Original goal" }, delivery: "steer" }) + expect(waits).toBe(2) + expect((await getGoal("ses_v2"))?.objective).toBe("A separate goal") +}) + +test("V2 control commands and disabled auto-continuation return after their execution", async () => { + for (const [text, autoContinue] of [ + ["history", true], + ["Original goal", false], + ] as const) { + const mock = makeMockContext({ auto_continue: autoContinue }) + let waits = 0 + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + waits++ + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Original goal") + await mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text }, delivery: "steer" }) + expect(waits).toBe(text === "history" ? 0 : 1) + } +}) + 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) @@ -2306,3 +2442,85 @@ test("V2 completed tool failures do not clear retry state", async () => { mock.stream.end() await cleanup() }) + +test("V2 command cancellation between idle cycles persists cancellation and stops the wait", async () => { + const mock = makeMockContext() + let waits = 0 + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + waits++ + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Goal with idle gaps") + const controller = new AbortController() + const command = mock.commands + .find((command) => command.name === "goal")! + .execute( + { sessionID: "ses_v2", prompt: { text: "Goal with idle gaps" }, delivery: "steer" }, + { signal: controller.signal }, + ) + await waitFor(() => waits > 0) + controller.abort() + await command + expect((await getGoal("ses_v2"))?.status).toBe("cancelled") + expect(mock.promptCalls).toHaveLength(1) +}) + +test("V2 wait failure pauses the goal and returns without hidden autonomous continuation", async () => { + const mock = makeMockContext() + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + throw new Error("session unavailable") + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Wait failure") + await mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "Wait failure" }, delivery: "steer" }) + expect((await getGoal("ses_v2"))?.status).toBe("paused") + expect(mock.promptCalls).toHaveLength(1) +}) + +for (const report of ["status", "show", "current"]) { + test(`V2 /goal ${report} returns without waiting for the active goal`, async () => { + const mock = makeMockContext() + let waits = 0 + const context = { + ...mock, + session: { + ...mock.session, + wait: async () => { + waits++ + }, + }, + } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Original full goal") + await mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: report }, delivery: "steer" }) + expect(waits).toBe(0) + }) +} + +test("V2 a stopped event stream pauses a waiting goal rather than polling forever", async () => { + const mock = makeMockContext() + const context = { ...mock, session: { ...mock.session, wait: async () => {} } } + await setupPlugin(context as never) + await createGoalViaV2Tool(mock, "Goal with event delivery") + mock.stream.end() + await mock.commands + .find((command) => command.name === "goal")! + .execute({ sessionID: "ses_v2", prompt: { text: "Continue the goal" }, delivery: "steer" }) + expect((await getGoal("ses_v2"))?.status).toBe("paused") +})