Skip to content

Commit 4d45479

Browse files
authored
Merge pull request #65 from prevalentWare/codex/goal-acp-lifetime
fix: keep goal commands pending across execution cycles
2 parents 8e71666 + 60575e3 commit 4d45479

7 files changed

Lines changed: 362 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,7 @@ The goal sidebar shows the current status, elapsed time, token usage, auto-conti
326326

327327
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.
328328

329-
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.
329+
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.
330+
331+
332+
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.

‎bun.lock‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/server.js‎

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { z } from "zod";
66
import { randomUUID as randomUUID2 } from "crypto";
77
import { mkdir, readFile } from "fs/promises";
88
import { dirname as dirname2 } from "path";
9-
import { Data, Effect, Schema } from "effect";
9+
import { Data, Effect, Schema } from "effect-goal-state";
1010

1111
// src/atomic-write.ts
1212
import { randomUUID } from "crypto";
@@ -3744,6 +3744,7 @@ async function setupV2(context) {
37443744
};
37453745
const registrations = [];
37463746
let disposed = false;
3747+
let eventConsumerStopped = false;
37473748
function stepKey(sessionID, messageID2) {
37483749
return `${sessionID}\x00${messageID2}`;
37493750
}
@@ -4391,7 +4392,7 @@ async function setupV2(context) {
43914392
draft.add({
43924393
name: command.name,
43934394
description: command.description,
4394-
execute: async (input) => {
4395+
execute: async (input, execution) => {
43954396
markSessionOwnership(input.sessionID, true);
43964397
if (command.action === "pause") {
43974398
const goal = await getGoal(input.sessionID);
@@ -4414,12 +4415,63 @@ async function setupV2(context) {
44144415
...skills ? { skills: skills.map(stripMention) } : {}
44154416
};
44164417
}
4417-
await context.session.prompt({
4418-
...forwardedPrompt,
4419-
sessionID: input.sessionID,
4420-
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText2(input.prompt.text.trim())),
4421-
delivery: input.delivery
4422-
});
4418+
const args = input.prompt.text.trim().toLowerCase();
4419+
const controlOnly = /^(history|status|show|current|pause|stop|cancel|clear|off|reset|none|edit)(?:\s|$)/.test(args);
4420+
const pursue = command.action === "resume" || command.action === "goal" && args !== "" && !controlOnly;
4421+
const signal = execution?.signal ? AbortSignal.any([abortController.signal, execution.signal]) : abortController.signal;
4422+
let admitted = false;
4423+
let cancellation;
4424+
const cancel = () => {
4425+
if (!pursue || disposed)
4426+
return;
4427+
goalServices.stopAutonomy?.(input.sessionID);
4428+
cancellation = cancelActiveGoal(input.sessionID).catch((error) => v2ErrorLog("Failed to persist command cancellation", error));
4429+
};
4430+
execution?.signal?.addEventListener("abort", cancel, { once: true });
4431+
try {
4432+
if (execution?.signal?.aborted) {
4433+
cancel();
4434+
return;
4435+
}
4436+
await context.session.prompt({
4437+
...forwardedPrompt,
4438+
sessionID: input.sessionID,
4439+
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText2(input.prompt.text.trim())),
4440+
delivery: input.delivery
4441+
});
4442+
admitted = true;
4443+
if (pursue && typeof context.session.wait === "function") {
4444+
let pursuedGoalID;
4445+
do {
4446+
await context.session.wait({ sessionID: input.sessionID }, { signal });
4447+
if (eventConsumerStopped)
4448+
throw new Error("goal event stream stopped");
4449+
const goal = await getGoal(input.sessionID);
4450+
if (signal.aborted || disposed || stoppedExecutions.has(input.sessionID) || !autoContinue || goal?.status !== "active")
4451+
break;
4452+
pursuedGoalID ??= goal.id;
4453+
if (goal.id !== pursuedGoalID)
4454+
break;
4455+
await new Promise((resolve) => setTimeout(resolve, 250));
4456+
} while (!disposed && !signal.aborted);
4457+
}
4458+
} catch (error) {
4459+
if (!admitted && !disposed && !execution?.signal?.aborted)
4460+
throw error;
4461+
if (!disposed && !execution?.signal?.aborted) {
4462+
goalServices.stopAutonomy?.(input.sessionID);
4463+
v2ErrorLog("Goal command wait failed; autonomous continuation stopped", error);
4464+
try {
4465+
if ((await getGoal(input.sessionID))?.status === "active")
4466+
await setGoalStatus(input.sessionID, "paused");
4467+
} catch (stateError) {
4468+
v2ErrorLog("Failed to pause goal after command failure", stateError);
4469+
}
4470+
}
4471+
} finally {
4472+
execution?.signal?.removeEventListener("abort", cancel);
4473+
await cancellation;
4474+
}
44234475
}
44244476
});
44254477
}
@@ -4552,6 +4604,9 @@ async function setupV2(context) {
45524604
} catch (error) {
45534605
if (!abortController.signal.aborted)
45544606
v2ErrorLog("V2 event consumer stopped", error);
4607+
} finally {
4608+
if (!disposed && !abortController.signal.aborted)
4609+
eventConsumerStopped = true;
45554610
}
45564611
})();
45574612
return async () => {

‎package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
],
4747
"scripts": {
4848
"clean": "rm -rf dist",
49-
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external effect --external zod",
49+
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external effect-goal-state --external zod",
5050
"ci:version": "bun scripts/resolve-ci-version.ts",
5151
"lint": "eslint .",
5252
"pack:dry-run": "npm pack --dry-run",
@@ -58,8 +58,8 @@
5858
},
5959
"dependencies": {
6060
"@opencode-ai/plugin": "^1.17.1",
61-
"effect": "^3.21.2",
62-
"zod": "^4.1.8"
61+
"zod": "^4.1.8",
62+
"effect-goal-state": "npm:effect@^3.21.2"
6363
},
6464
"peerDependencies": {
6565
"@opentui/solid": ">=0.5.8",

‎src/server.ts‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2211,6 +2211,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
22112211
}
22122212
const registrations: Array<{ dispose(): Promise<void> }> = []
22132213
let disposed = false
2214+
let eventConsumerStopped = false
22142215

22152216
function stepKey(sessionID: string, messageID: string) {
22162217
return `${sessionID}\0${messageID}`
@@ -2935,7 +2936,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
29352936
draft.add({
29362937
name: command.name,
29372938
description: command.description,
2938-
execute: async (input) => {
2939+
execute: async (input, execution?: { signal?: AbortSignal }) => {
29392940
// Command execution is routed to the session's owning location.
29402941
markSessionOwnership(input.sessionID, true)
29412942
if (command.action === "pause") {
@@ -2961,12 +2962,70 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
29612962
...(skills ? { skills: skills.map(stripMention) } : {}),
29622963
}
29632964
}
2964-
await context.session.prompt({
2965-
...forwardedPrompt,
2966-
sessionID: input.sessionID,
2967-
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText(input.prompt.text.trim())),
2968-
delivery: input.delivery,
2969-
})
2965+
const args = input.prompt.text.trim().toLowerCase()
2966+
const controlOnly = /^(history|status|show|current|pause|stop|cancel|clear|off|reset|none|edit)(?:\s|$)/.test(args)
2967+
const pursue = command.action === "resume" || (command.action === "goal" && args !== "" && !controlOnly)
2968+
const signal = execution?.signal
2969+
? AbortSignal.any([abortController.signal, execution.signal])
2970+
: abortController.signal
2971+
let admitted = false
2972+
let cancellation: Promise<unknown> | undefined
2973+
const cancel = () => {
2974+
if (!pursue || disposed) return
2975+
goalServices.stopAutonomy?.(input.sessionID)
2976+
cancellation = cancelActiveGoal(input.sessionID).catch((error) =>
2977+
v2ErrorLog("Failed to persist command cancellation", error),
2978+
)
2979+
}
2980+
execution?.signal?.addEventListener("abort", cancel, { once: true })
2981+
try {
2982+
if (execution?.signal?.aborted) {
2983+
cancel()
2984+
return
2985+
}
2986+
await context.session.prompt({
2987+
...forwardedPrompt,
2988+
sessionID: input.sessionID,
2989+
text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText(input.prompt.text.trim())),
2990+
delivery: input.delivery,
2991+
})
2992+
admitted = true
2993+
// A goal command owns all its automatic execution cycles.
2994+
// Older hosts without wait retain admission-only behavior.
2995+
if (pursue && typeof context.session.wait === "function") {
2996+
let pursuedGoalID: string | undefined
2997+
do {
2998+
await context.session.wait({ sessionID: input.sessionID }, { signal })
2999+
if (eventConsumerStopped) throw new Error("goal event stream stopped")
3000+
const goal = await getGoal(input.sessionID)
3001+
if (
3002+
signal.aborted ||
3003+
disposed ||
3004+
stoppedExecutions.has(input.sessionID) ||
3005+
!autoContinue ||
3006+
goal?.status !== "active"
3007+
)
3008+
break
3009+
pursuedGoalID ??= goal.id
3010+
if (goal.id !== pursuedGoalID) break
3011+
await new Promise((resolve) => setTimeout(resolve, 250))
3012+
} while (!disposed && !signal.aborted)
3013+
}
3014+
} catch (error) {
3015+
if (!admitted && !disposed && !execution?.signal?.aborted) throw error
3016+
if (!disposed && !execution?.signal?.aborted) {
3017+
goalServices.stopAutonomy?.(input.sessionID)
3018+
v2ErrorLog("Goal command wait failed; autonomous continuation stopped", error)
3019+
try {
3020+
if ((await getGoal(input.sessionID))?.status === "active") await setGoalStatus(input.sessionID, "paused")
3021+
} catch (stateError) {
3022+
v2ErrorLog("Failed to pause goal after command failure", stateError)
3023+
}
3024+
}
3025+
} finally {
3026+
execution?.signal?.removeEventListener("abort", cancel)
3027+
await cancellation
3028+
}
29703029
},
29713030
})
29723031
}
@@ -3139,6 +3198,8 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
31393198
}
31403199
} catch (error) {
31413200
if (!abortController.signal.aborted) v2ErrorLog("V2 event consumer stopped", error)
3201+
} finally {
3202+
if (!disposed && !abortController.signal.aborted) eventConsumerStopped = true
31423203
}
31433204
})()
31443205

‎src/state.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"
22
import { readFileSync } from "node:fs"
33
import { mkdir, readFile } from "node:fs/promises"
44
import { dirname } from "node:path"
5-
import { Data, Effect, Schema } from "effect"
5+
import { Data, Effect, Schema } from "effect-goal-state"
66
import { atomicWriteFile } from "./atomic-write"
77
import { statePath } from "./state-path"
88

0 commit comments

Comments
 (0)