@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 +6 -1
- package/dist/server.js +72 -9
- package/package.json +4 -4
- package/src/tui.ts +3 -1
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
|
-
|
|
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
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
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.
|
|
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
|
-
"
|
|
62
|
-
"
|
|
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"
|