@prevalentware/opencode-goal-plugin 0.1.41 → 0.1.42
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 +2 -0
- package/dist/server.js +124 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,6 +231,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po
|
|
|
231
231
|
|
|
232
232
|
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.
|
|
233
233
|
|
|
234
|
+
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.
|
|
235
|
+
|
|
234
236
|
## Credits
|
|
235
237
|
|
|
236
238
|
This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William.
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/state.ts
|
|
6
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6
7
|
import { mkdir, readFile } from "fs/promises";
|
|
7
8
|
import { homedir } from "os";
|
|
8
9
|
import { dirname as dirname2, join } from "path";
|
|
@@ -220,6 +221,23 @@ function mutableState(state) {
|
|
|
220
221
|
return JSON.parse(JSON.stringify(state));
|
|
221
222
|
}
|
|
222
223
|
var warnedEmptyStatePaths = new Set;
|
|
224
|
+
var stateRecoveryListeners = new Set;
|
|
225
|
+
function onStateRecovery(stateFile, report) {
|
|
226
|
+
const listener = { stateFile, report };
|
|
227
|
+
stateRecoveryListeners.add(listener);
|
|
228
|
+
return () => stateRecoveryListeners.delete(listener);
|
|
229
|
+
}
|
|
230
|
+
function notifyStateRecovery(notice) {
|
|
231
|
+
for (const listener of stateRecoveryListeners) {
|
|
232
|
+
if (listener.stateFile !== notice.stateFile)
|
|
233
|
+
continue;
|
|
234
|
+
Promise.resolve().then(() => listener.report(notice)).catch((error) => {
|
|
235
|
+
try {
|
|
236
|
+
console.error(`[opencode-goal-plugin] Failed to report quarantined state at ${notice.quarantineFile}:`, error instanceof Error ? error.message : String(error));
|
|
237
|
+
} catch {}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
223
241
|
function isStatePadding(character) {
|
|
224
242
|
return character === "\x00" || character.trim() === "";
|
|
225
243
|
}
|
|
@@ -232,24 +250,53 @@ function parseStateText(raw, file) {
|
|
|
232
250
|
end -= 1;
|
|
233
251
|
const content = raw.slice(start, end);
|
|
234
252
|
if (content)
|
|
235
|
-
return JSON.parse(content);
|
|
253
|
+
return { value: JSON.parse(content), recoveryContent: null };
|
|
236
254
|
if (!warnedEmptyStatePaths.has(file)) {
|
|
237
255
|
warnedEmptyStatePaths.add(file);
|
|
238
256
|
console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`);
|
|
239
257
|
}
|
|
240
|
-
return emptyState();
|
|
258
|
+
return { value: emptyState(), recoveryContent: raw || null };
|
|
241
259
|
}
|
|
242
260
|
function decodeState(value) {
|
|
243
261
|
return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
|
|
244
262
|
}
|
|
245
|
-
function
|
|
263
|
+
function readStateResultEffect(file = statePath()) {
|
|
246
264
|
return Effect.tryPromise({
|
|
247
265
|
try: () => readFile(file, "utf8"),
|
|
248
266
|
catch: (cause) => new StateReadError({ cause })
|
|
249
267
|
}).pipe(Effect.flatMap((raw) => Effect.try({
|
|
250
268
|
try: () => parseStateText(raw, file),
|
|
251
269
|
catch: (cause) => new StateDecodeError({ cause })
|
|
252
|
-
})), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
|
|
270
|
+
})), Effect.flatMap(({ value, recoveryContent }) => decodeState(value).pipe(Effect.map((state) => ({ state, recoveryContent })))), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed({ state: emptyState(), recoveryContent: null }) : Effect.fail(error)));
|
|
271
|
+
}
|
|
272
|
+
function readStateEffect(file = statePath()) {
|
|
273
|
+
return readStateResultEffect(file).pipe(Effect.map(({ state }) => state));
|
|
274
|
+
}
|
|
275
|
+
function quarantineStateEffect(file, content) {
|
|
276
|
+
return Effect.promise(async () => {
|
|
277
|
+
const quarantineFile = `${file}.corrupt-${Date.now()}-${randomUUID2()}`;
|
|
278
|
+
try {
|
|
279
|
+
await mkdir(dirname2(file), { recursive: true, mode: 448 });
|
|
280
|
+
await atomicWriteFile(quarantineFile, content);
|
|
281
|
+
return { quarantineFile, error: null };
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { quarantineFile, error: error instanceof Error ? error.message : String(error) };
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function verifyRecoverySourceEffect(file, expectedContent, quarantineFile) {
|
|
288
|
+
return Effect.promise(async () => {
|
|
289
|
+
try {
|
|
290
|
+
return await readFile(file, "utf8") === expectedContent;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!isMissingStateFile(error)) {
|
|
293
|
+
try {
|
|
294
|
+
console.error(`[opencode-goal-plugin] Could not re-read ${file} after preserving it at ${quarantineFile}; continuing recovery:`, error instanceof Error ? error.message : String(error));
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
253
300
|
}
|
|
254
301
|
function writeStateEffect(state, file = statePath()) {
|
|
255
302
|
return Effect.tryPromise({
|
|
@@ -278,11 +325,46 @@ async function mutate(fn) {
|
|
|
278
325
|
return enqueueMutation(() => {
|
|
279
326
|
const file = statePath();
|
|
280
327
|
return Effect.runPromise(Effect.gen(function* () {
|
|
281
|
-
const state = yield*
|
|
328
|
+
const { state, recoveryContent } = yield* readStateResultEffect(file);
|
|
282
329
|
const result = yield* Effect.tryPromise({
|
|
283
330
|
try: () => Promise.resolve(fn(state)),
|
|
284
331
|
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
285
332
|
});
|
|
333
|
+
if (recoveryContent != null) {
|
|
334
|
+
const quarantine = yield* quarantineStateEffect(file, recoveryContent);
|
|
335
|
+
if (quarantine.error != null) {
|
|
336
|
+
const notice = {
|
|
337
|
+
stateFile: file,
|
|
338
|
+
quarantineFile: quarantine.quarantineFile,
|
|
339
|
+
outcome: "quarantineFailed",
|
|
340
|
+
error: quarantine.error
|
|
341
|
+
};
|
|
342
|
+
try {
|
|
343
|
+
console.error(`[opencode-goal-plugin] Could not quarantine corrupt state at ${file}; continuing recovery:`, quarantine.error);
|
|
344
|
+
} catch {}
|
|
345
|
+
notifyStateRecovery(notice);
|
|
346
|
+
} else {
|
|
347
|
+
const unchanged = yield* verifyRecoverySourceEffect(file, recoveryContent, quarantine.quarantineFile);
|
|
348
|
+
if (!unchanged) {
|
|
349
|
+
const message = "goal state changed while recovery was being quarantined; refusing to overwrite it";
|
|
350
|
+
notifyStateRecovery({
|
|
351
|
+
stateFile: file,
|
|
352
|
+
quarantineFile: quarantine.quarantineFile,
|
|
353
|
+
outcome: "sourceChanged",
|
|
354
|
+
error: message
|
|
355
|
+
});
|
|
356
|
+
return yield* Effect.fail(new StateWriteError({ cause: new Error(message) }));
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
console.warn(`[opencode-goal-plugin] Preserved corrupt state from ${file} at ${quarantine.quarantineFile}; continuing recovery.`);
|
|
360
|
+
} catch {}
|
|
361
|
+
notifyStateRecovery({
|
|
362
|
+
stateFile: file,
|
|
363
|
+
quarantineFile: quarantine.quarantineFile,
|
|
364
|
+
outcome: "quarantined"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
286
368
|
yield* writeStateEffect(state, file);
|
|
287
369
|
return result;
|
|
288
370
|
}));
|
|
@@ -903,8 +985,8 @@ async function markPendingContinuationStarted(sessionID) {
|
|
|
903
985
|
return current ? snapshotInternal(current) : null;
|
|
904
986
|
if (current.pendingAttempt == null || current.pendingAttempt.started)
|
|
905
987
|
return snapshotInternal(current);
|
|
906
|
-
return mutate((
|
|
907
|
-
const goal =
|
|
988
|
+
return mutate((state) => {
|
|
989
|
+
const goal = state.goals[sessionID];
|
|
908
990
|
if (!goal || goal.status !== "active")
|
|
909
991
|
return goal ? snapshotInternal(goal) : null;
|
|
910
992
|
if (goal.pendingAttempt == null || goal.pendingAttempt.started)
|
|
@@ -1851,10 +1933,10 @@ async function updateGoalObjectiveFromTool(input, context, services) {
|
|
|
1851
1933
|
}
|
|
1852
1934
|
async function closeGoalFromTool(input, context) {
|
|
1853
1935
|
if (input.status === "complete") {
|
|
1854
|
-
const
|
|
1855
|
-
const budget =
|
|
1856
|
-
const
|
|
1857
|
-
return JSON.stringify({ goal
|
|
1936
|
+
const goal = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
1937
|
+
const budget = goal.tokenBudget == null ? "" : ` Token usage: ${goal.tokensUsed}/${goal.tokenBudget}.`;
|
|
1938
|
+
const report = `Goal achieved. Time used: ${goal.timeUsedSeconds} seconds.${budget} Evidence: ${goal.completionEvidence}.`;
|
|
1939
|
+
return JSON.stringify({ goal, completion_report: report }, null, 2);
|
|
1858
1940
|
}
|
|
1859
1941
|
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
1860
1942
|
const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
@@ -1932,6 +2014,16 @@ var server = async ({ client }, options) => {
|
|
|
1932
2014
|
const planAgents = restrictedAgentSet(options);
|
|
1933
2015
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1934
2016
|
const goalServices = { options: options ?? {}, isPlanAgent };
|
|
2017
|
+
const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => {
|
|
2018
|
+
await client.app?.log?.({
|
|
2019
|
+
body: {
|
|
2020
|
+
service: "opencode-goal-plugin",
|
|
2021
|
+
level: "error",
|
|
2022
|
+
message: outcome === "quarantined" ? "Corrupt goal state quarantined before recovery" : outcome === "sourceChanged" ? "Goal state changed during recovery; refusing to overwrite it" : "Corrupt goal state could not be quarantined; continuing recovery",
|
|
2023
|
+
extra: { stateFile, quarantineFile, outcome, ...error ? { error } : {} }
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
1935
2027
|
let disposed = false;
|
|
1936
2028
|
async function taskBlockStatus(sessionID) {
|
|
1937
2029
|
if (!deferWhileTasksActive)
|
|
@@ -2180,6 +2272,7 @@ var server = async ({ client }, options) => {
|
|
|
2180
2272
|
return {
|
|
2181
2273
|
async dispose() {
|
|
2182
2274
|
disposed = true;
|
|
2275
|
+
stopStateRecoveryReporting();
|
|
2183
2276
|
for (const scheduled of scheduledContinuations.values())
|
|
2184
2277
|
clearTimeout(scheduled.timer);
|
|
2185
2278
|
scheduledContinuations.clear();
|
|
@@ -2496,10 +2589,10 @@ async function setupV2(context) {
|
|
|
2496
2589
|
};
|
|
2497
2590
|
const registrations = [];
|
|
2498
2591
|
let disposed = false;
|
|
2499
|
-
function stepKey(sessionID,
|
|
2500
|
-
return `${sessionID}\x00${
|
|
2592
|
+
function stepKey(sessionID, messageID) {
|
|
2593
|
+
return `${sessionID}\x00${messageID}`;
|
|
2501
2594
|
}
|
|
2502
|
-
async function
|
|
2595
|
+
async function sendContinuation(sessionID, prompt, agent) {
|
|
2503
2596
|
await context.session.prompt({
|
|
2504
2597
|
sessionID,
|
|
2505
2598
|
text: prompt,
|
|
@@ -2564,7 +2657,7 @@ async function setupV2(context) {
|
|
|
2564
2657
|
activeContinuationsV2.add(sessionID);
|
|
2565
2658
|
claimedContinuation = true;
|
|
2566
2659
|
watchdogRescuedSessions.add(sessionID);
|
|
2567
|
-
await
|
|
2660
|
+
await sendContinuation(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
|
|
2568
2661
|
await recordContinuationResult(sessionID, "success", maxPromptFailures, { armNoProgress: false, started: true });
|
|
2569
2662
|
locallyDeliveredPendingSessions.add(sessionID);
|
|
2570
2663
|
clearTurnWatchdog(sessionID);
|
|
@@ -2716,7 +2809,7 @@ async function setupV2(context) {
|
|
|
2716
2809
|
await rollbackContinuationAttempt(sessionID);
|
|
2717
2810
|
return;
|
|
2718
2811
|
}
|
|
2719
|
-
await
|
|
2812
|
+
await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
2720
2813
|
if (disposed) {
|
|
2721
2814
|
await rollbackContinuationAttempt(sessionID);
|
|
2722
2815
|
return;
|
|
@@ -2861,16 +2954,16 @@ async function setupV2(context) {
|
|
|
2861
2954
|
case "session.step.started": {
|
|
2862
2955
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2863
2956
|
return;
|
|
2864
|
-
const
|
|
2957
|
+
const messageID = data.assistantMessageID;
|
|
2865
2958
|
const agent = typeof data.agent === "string" ? data.agent : undefined;
|
|
2866
2959
|
if (agent)
|
|
2867
2960
|
await recordPromptAgent(sessionID, agent);
|
|
2868
2961
|
taskTracker.observeAssistantMessage(sessionID, {
|
|
2869
|
-
info: { id:
|
|
2962
|
+
info: { id: messageID, role: "assistant", time: { completed: event.created } }
|
|
2870
2963
|
});
|
|
2871
|
-
if (!stepTextBuffers.has(stepKey(sessionID,
|
|
2872
|
-
stepTextBuffers.set(stepKey(sessionID,
|
|
2873
|
-
latestStepBySession.set(sessionID, { messageID
|
|
2964
|
+
if (!stepTextBuffers.has(stepKey(sessionID, messageID)))
|
|
2965
|
+
stepTextBuffers.set(stepKey(sessionID, messageID), "");
|
|
2966
|
+
latestStepBySession.set(sessionID, { messageID, agent, text: "", outputTokens: null, completedAt: event.created });
|
|
2874
2967
|
return;
|
|
2875
2968
|
}
|
|
2876
2969
|
case "session.text.delta": {
|
|
@@ -2889,7 +2982,7 @@ async function setupV2(context) {
|
|
|
2889
2982
|
case "session.step.ended": {
|
|
2890
2983
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2891
2984
|
return;
|
|
2892
|
-
const
|
|
2985
|
+
const messageID = data.assistantMessageID;
|
|
2893
2986
|
const tokens = tokensFromRecord(data.tokens);
|
|
2894
2987
|
if (typeof tokens === "number") {
|
|
2895
2988
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2900,11 +2993,11 @@ async function setupV2(context) {
|
|
|
2900
2993
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2901
2994
|
});
|
|
2902
2995
|
}
|
|
2903
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2904
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
2996
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
2997
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2905
2998
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2906
2999
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2907
|
-
messageID
|
|
3000
|
+
messageID,
|
|
2908
3001
|
text,
|
|
2909
3002
|
outputTokens,
|
|
2910
3003
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2918,7 +3011,7 @@ async function setupV2(context) {
|
|
|
2918
3011
|
cancelScheduledContinuation(sessionID);
|
|
2919
3012
|
}
|
|
2920
3013
|
latestStepBySession.set(sessionID, {
|
|
2921
|
-
messageID
|
|
3014
|
+
messageID,
|
|
2922
3015
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2923
3016
|
text,
|
|
2924
3017
|
outputTokens,
|
|
@@ -2929,7 +3022,7 @@ async function setupV2(context) {
|
|
|
2929
3022
|
case "session.step.failed": {
|
|
2930
3023
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2931
3024
|
return;
|
|
2932
|
-
const
|
|
3025
|
+
const messageID = data.assistantMessageID;
|
|
2933
3026
|
const tokens = tokensFromRecord(data.tokens);
|
|
2934
3027
|
if (typeof tokens === "number") {
|
|
2935
3028
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2940,11 +3033,11 @@ async function setupV2(context) {
|
|
|
2940
3033
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2941
3034
|
});
|
|
2942
3035
|
}
|
|
2943
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2944
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
3036
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
3037
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2945
3038
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2946
3039
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2947
|
-
messageID
|
|
3040
|
+
messageID,
|
|
2948
3041
|
text,
|
|
2949
3042
|
outputTokens,
|
|
2950
3043
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2958,7 +3051,7 @@ async function setupV2(context) {
|
|
|
2958
3051
|
cancelScheduledContinuation(sessionID);
|
|
2959
3052
|
}
|
|
2960
3053
|
latestStepBySession.set(sessionID, {
|
|
2961
|
-
messageID
|
|
3054
|
+
messageID,
|
|
2962
3055
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2963
3056
|
text,
|
|
2964
3057
|
outputTokens,
|
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.42",
|
|
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",
|