@prevalentware/opencode-goal-plugin 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -259,6 +259,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po
259
259
 
260
260
  The plugin migrates version 1 state files to version 2 on the next write. Version 2 adds stable goal identities, the terminal `cancelled` status, and compact archives. Older plugin versions intentionally reject version 2 instead of silently dropping archived history on their next write; back up or isolate `OPENCODE_GOAL_STATE_PATH` before downgrading.
261
261
 
262
+ Planless version 3 files from the planning development build are also compatible: empty `plan: null` and `planRevision: 0` metadata survives reads, updates, and archival, and writes retain version 3. This prevents an otherwise valid shared state file from breaking ordinary OpenCode tool calls in sessions without a goal. Populated plans, nonzero planning revisions, and unknown future versions remain rejected without rewriting the file; use the corresponding planning build or an isolated `OPENCODE_GOAL_STATE_PATH` for those files.
263
+
262
264
  If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.
263
265
 
264
266
  If a non-empty state file contains only whitespace, a UTF-8 BOM, or NUL bytes after an interrupted write, the next mutation preserves its exact contents beside the state file as `goals.json.corrupt-<timestamp>-<uuid>` before writing recovered state. If another process replaces the state during recovery, the mutation refuses to overwrite that newer content. If the quarantine copy itself cannot be created, the plugin reports the failure and continues recovery rather than making every prompt fail indefinitely. OpenCode 1 also records the quarantine outcome and path through its application log so the data-loss event remains discoverable.
@@ -324,4 +326,7 @@ The goal sidebar shows the current status, elapsed time, token usage, auto-conti
324
326
 
325
327
  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.
326
328
 
327
- 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.
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { randomUUID as randomUUID2 } from "crypto";
7
7
  import { mkdir, readFile } from "fs/promises";
8
8
  import { dirname as dirname2 } from "path";
9
- import { Data, Effect, Schema } from "effect";
9
+ import { Data, Effect, Schema } from "effect-goal-state";
10
10
 
11
11
  // src/atomic-write.ts
12
12
  import { randomUUID } from "crypto";
@@ -179,6 +179,8 @@ var GoalSchema = Schema.Struct({
179
179
  id: Schema.optionalWith(Schema.String, { default: () => "" }),
180
180
  sessionID: Schema.String,
181
181
  objective: Schema.String,
182
+ plan: Schema.optional(Schema.Null),
183
+ planRevision: Schema.optional(Schema.Literal(0)),
182
184
  status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
183
185
  tokenBudget: NullableNumber,
184
186
  tokensUsed: Schema.Number,
@@ -216,6 +218,8 @@ var ArchivedGoalSchema = Schema.Struct({
216
218
  id: Schema.String,
217
219
  sessionID: Schema.String,
218
220
  objective: Schema.String,
221
+ plan: Schema.optional(Schema.Null),
222
+ planRevision: Schema.optional(Schema.Literal(0)),
219
223
  status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
220
224
  tokenBudget: NullableNumber,
221
225
  tokensUsed: Schema.Number,
@@ -234,7 +238,7 @@ var LegacyStateSchema = Schema.Struct({
234
238
  goals: Schema.Record({ key: Schema.String, value: GoalSchema })
235
239
  });
236
240
  var StateSchema = Schema.Struct({
237
- version: Schema.Literal(2),
241
+ version: Schema.Literal(2, 3),
238
242
  goals: Schema.Record({ key: Schema.String, value: GoalSchema }),
239
243
  archives: Schema.optionalWith(Schema.Record({ key: Schema.String, value: Schema.Array(ArchivedGoalSchema) }), {
240
244
  default: () => ({})
@@ -551,6 +555,8 @@ function snapshot(goal) {
551
555
  id: goal.id,
552
556
  sessionID: goal.sessionID,
553
557
  objective: goal.objective,
558
+ ...goal.plan === null ? { plan: null } : {},
559
+ ...goal.planRevision === 0 ? { planRevision: 0 } : {},
554
560
  status: goal.status,
555
561
  tokenBudget: goal.tokenBudget,
556
562
  tokensUsed: goal.tokensUsed,
@@ -678,6 +684,8 @@ function archivedGoal(goal) {
678
684
  id: goal.id,
679
685
  sessionID: goal.sessionID,
680
686
  objective: summarizeText(goal.objective, MAX_ARCHIVED_OBJECTIVE_CHARS),
687
+ ...goal.plan === null ? { plan: null } : {},
688
+ ...goal.planRevision === 0 ? { planRevision: 0 } : {},
681
689
  status: goal.status,
682
690
  tokenBudget: goal.tokenBudget,
683
691
  tokensUsed: goal.tokensUsed,
@@ -3736,6 +3744,7 @@ async function setupV2(context) {
3736
3744
  };
3737
3745
  const registrations = [];
3738
3746
  let disposed = false;
3747
+ let eventConsumerStopped = false;
3739
3748
  function stepKey(sessionID, messageID) {
3740
3749
  return `${sessionID}\x00${messageID}`;
3741
3750
  }
@@ -4383,7 +4392,7 @@ async function setupV2(context) {
4383
4392
  draft.add({
4384
4393
  name: command.name,
4385
4394
  description: command.description,
4386
- execute: async (input) => {
4395
+ execute: async (input, execution) => {
4387
4396
  markSessionOwnership(input.sessionID, true);
4388
4397
  if (command.action === "pause") {
4389
4398
  const goal = await getGoal(input.sessionID);
@@ -4406,12 +4415,63 @@ async function setupV2(context) {
4406
4415
  ...skills ? { skills: skills.map(stripMention) } : {}
4407
4416
  };
4408
4417
  }
4409
- await context.session.prompt({
4410
- ...forwardedPrompt,
4411
- sessionID: input.sessionID,
4412
- text: command.template.replaceAll("$ARGUMENTS", () => escapeXmlText2(input.prompt.text.trim())),
4413
- delivery: input.delivery
4414
- });
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
+ }
4415
4475
  }
4416
4476
  });
4417
4477
  }
@@ -4544,6 +4604,9 @@ async function setupV2(context) {
4544
4604
  } catch (error) {
4545
4605
  if (!abortController.signal.aborted)
4546
4606
  v2ErrorLog("V2 event consumer stopped", error);
4607
+ } finally {
4608
+ if (!disposed && !abortController.signal.aborted)
4609
+ eventConsumerStopped = true;
4547
4610
  }
4548
4611
  })();
4549
4612
  return async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -46,7 +46,7 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "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",
50
50
  "ci:version": "bun scripts/resolve-ci-version.ts",
51
51
  "lint": "eslint .",
52
52
  "pack:dry-run": "npm pack --dry-run",
@@ -58,8 +58,8 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@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"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@opentui/solid": ">=0.5.8",
package/src/tui.ts CHANGED
@@ -419,10 +419,12 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
419
419
  export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
420
420
  try {
421
421
  const state: unknown = JSON.parse(await readFile(statePath(), "utf8"))
422
- if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined
422
+ if (!isRecord(state) || (state.version !== 1 && state.version !== 2 && state.version !== 3) || !isRecord(state.goals)) return undefined
423
423
  const stored = state.goals[sessionID]
424
424
  if (stored === undefined) return null
425
425
  if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
426
+ if (stored.plan !== undefined && stored.plan !== null) return undefined
427
+ if (stored.planRevision !== undefined && stored.planRevision !== 0) return undefined
426
428
  const sampledAt = currentEpochSeconds()
427
429
  const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
428
430
  const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"