@prevalentware/opencode-goal-plugin 0.1.40 → 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 +142 -32
- 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}.`;
|
|
@@ -1875,6 +1957,21 @@ function v2ObjectSchema(properties, required = []) {
|
|
|
1875
1957
|
additionalProperties: false
|
|
1876
1958
|
};
|
|
1877
1959
|
}
|
|
1960
|
+
function decodeV2Event(value) {
|
|
1961
|
+
let decoded = value;
|
|
1962
|
+
if (typeof decoded === "string") {
|
|
1963
|
+
try {
|
|
1964
|
+
decoded = JSON.parse(decoded);
|
|
1965
|
+
} catch {
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if (!isRecord(decoded) || typeof decoded.type !== "string" || !isRecord(decoded.data))
|
|
1970
|
+
return;
|
|
1971
|
+
if (typeof decoded.created !== "number")
|
|
1972
|
+
return;
|
|
1973
|
+
return decoded;
|
|
1974
|
+
}
|
|
1878
1975
|
function textFromToolResult(result) {
|
|
1879
1976
|
if (typeof result.output === "string")
|
|
1880
1977
|
return result.output;
|
|
@@ -1917,6 +2014,16 @@ var server = async ({ client }, options) => {
|
|
|
1917
2014
|
const planAgents = restrictedAgentSet(options);
|
|
1918
2015
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1919
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
|
+
});
|
|
1920
2027
|
let disposed = false;
|
|
1921
2028
|
async function taskBlockStatus(sessionID) {
|
|
1922
2029
|
if (!deferWhileTasksActive)
|
|
@@ -2165,6 +2272,7 @@ var server = async ({ client }, options) => {
|
|
|
2165
2272
|
return {
|
|
2166
2273
|
async dispose() {
|
|
2167
2274
|
disposed = true;
|
|
2275
|
+
stopStateRecoveryReporting();
|
|
2168
2276
|
for (const scheduled of scheduledContinuations.values())
|
|
2169
2277
|
clearTimeout(scheduled.timer);
|
|
2170
2278
|
scheduledContinuations.clear();
|
|
@@ -2481,10 +2589,10 @@ async function setupV2(context) {
|
|
|
2481
2589
|
};
|
|
2482
2590
|
const registrations = [];
|
|
2483
2591
|
let disposed = false;
|
|
2484
|
-
function stepKey(sessionID,
|
|
2485
|
-
return `${sessionID}\x00${
|
|
2592
|
+
function stepKey(sessionID, messageID) {
|
|
2593
|
+
return `${sessionID}\x00${messageID}`;
|
|
2486
2594
|
}
|
|
2487
|
-
async function
|
|
2595
|
+
async function sendContinuation(sessionID, prompt, agent) {
|
|
2488
2596
|
await context.session.prompt({
|
|
2489
2597
|
sessionID,
|
|
2490
2598
|
text: prompt,
|
|
@@ -2549,7 +2657,7 @@ async function setupV2(context) {
|
|
|
2549
2657
|
activeContinuationsV2.add(sessionID);
|
|
2550
2658
|
claimedContinuation = true;
|
|
2551
2659
|
watchdogRescuedSessions.add(sessionID);
|
|
2552
|
-
await
|
|
2660
|
+
await sendContinuation(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
|
|
2553
2661
|
await recordContinuationResult(sessionID, "success", maxPromptFailures, { armNoProgress: false, started: true });
|
|
2554
2662
|
locallyDeliveredPendingSessions.add(sessionID);
|
|
2555
2663
|
clearTurnWatchdog(sessionID);
|
|
@@ -2701,7 +2809,7 @@ async function setupV2(context) {
|
|
|
2701
2809
|
await rollbackContinuationAttempt(sessionID);
|
|
2702
2810
|
return;
|
|
2703
2811
|
}
|
|
2704
|
-
await
|
|
2812
|
+
await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
2705
2813
|
if (disposed) {
|
|
2706
2814
|
await rollbackContinuationAttempt(sessionID);
|
|
2707
2815
|
return;
|
|
@@ -2846,16 +2954,16 @@ async function setupV2(context) {
|
|
|
2846
2954
|
case "session.step.started": {
|
|
2847
2955
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2848
2956
|
return;
|
|
2849
|
-
const
|
|
2957
|
+
const messageID = data.assistantMessageID;
|
|
2850
2958
|
const agent = typeof data.agent === "string" ? data.agent : undefined;
|
|
2851
2959
|
if (agent)
|
|
2852
2960
|
await recordPromptAgent(sessionID, agent);
|
|
2853
2961
|
taskTracker.observeAssistantMessage(sessionID, {
|
|
2854
|
-
info: { id:
|
|
2962
|
+
info: { id: messageID, role: "assistant", time: { completed: event.created } }
|
|
2855
2963
|
});
|
|
2856
|
-
if (!stepTextBuffers.has(stepKey(sessionID,
|
|
2857
|
-
stepTextBuffers.set(stepKey(sessionID,
|
|
2858
|
-
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 });
|
|
2859
2967
|
return;
|
|
2860
2968
|
}
|
|
2861
2969
|
case "session.text.delta": {
|
|
@@ -2874,7 +2982,7 @@ async function setupV2(context) {
|
|
|
2874
2982
|
case "session.step.ended": {
|
|
2875
2983
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2876
2984
|
return;
|
|
2877
|
-
const
|
|
2985
|
+
const messageID = data.assistantMessageID;
|
|
2878
2986
|
const tokens = tokensFromRecord(data.tokens);
|
|
2879
2987
|
if (typeof tokens === "number") {
|
|
2880
2988
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2885,11 +2993,11 @@ async function setupV2(context) {
|
|
|
2885
2993
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2886
2994
|
});
|
|
2887
2995
|
}
|
|
2888
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2889
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
2996
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
2997
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2890
2998
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2891
2999
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2892
|
-
messageID
|
|
3000
|
+
messageID,
|
|
2893
3001
|
text,
|
|
2894
3002
|
outputTokens,
|
|
2895
3003
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2903,7 +3011,7 @@ async function setupV2(context) {
|
|
|
2903
3011
|
cancelScheduledContinuation(sessionID);
|
|
2904
3012
|
}
|
|
2905
3013
|
latestStepBySession.set(sessionID, {
|
|
2906
|
-
messageID
|
|
3014
|
+
messageID,
|
|
2907
3015
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2908
3016
|
text,
|
|
2909
3017
|
outputTokens,
|
|
@@ -2914,7 +3022,7 @@ async function setupV2(context) {
|
|
|
2914
3022
|
case "session.step.failed": {
|
|
2915
3023
|
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2916
3024
|
return;
|
|
2917
|
-
const
|
|
3025
|
+
const messageID = data.assistantMessageID;
|
|
2918
3026
|
const tokens = tokensFromRecord(data.tokens);
|
|
2919
3027
|
if (typeof tokens === "number") {
|
|
2920
3028
|
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
@@ -2925,11 +3033,11 @@ async function setupV2(context) {
|
|
|
2925
3033
|
initialBaseline: Math.ceil(sum - tokens)
|
|
2926
3034
|
});
|
|
2927
3035
|
}
|
|
2928
|
-
const text = stepTextBuffers.get(stepKey(sessionID,
|
|
2929
|
-
stepTextBuffers.delete(stepKey(sessionID,
|
|
3036
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID)) ?? "";
|
|
3037
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID));
|
|
2930
3038
|
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2931
3039
|
const afterStep = await recordAssistantProgress(sessionID, {
|
|
2932
|
-
messageID
|
|
3040
|
+
messageID,
|
|
2933
3041
|
text,
|
|
2934
3042
|
outputTokens,
|
|
2935
3043
|
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
@@ -2943,7 +3051,7 @@ async function setupV2(context) {
|
|
|
2943
3051
|
cancelScheduledContinuation(sessionID);
|
|
2944
3052
|
}
|
|
2945
3053
|
latestStepBySession.set(sessionID, {
|
|
2946
|
-
messageID
|
|
3054
|
+
messageID,
|
|
2947
3055
|
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2948
3056
|
text,
|
|
2949
3057
|
outputTokens,
|
|
@@ -3041,7 +3149,9 @@ async function setupV2(context) {
|
|
|
3041
3149
|
const { done, value } = await iterator.next();
|
|
3042
3150
|
if (done)
|
|
3043
3151
|
break;
|
|
3044
|
-
|
|
3152
|
+
const event = decodeV2Event(value);
|
|
3153
|
+
if (event)
|
|
3154
|
+
await handleV2Event(event);
|
|
3045
3155
|
}
|
|
3046
3156
|
} catch (error) {
|
|
3047
3157
|
if (!abortController.signal.aborted)
|
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",
|