@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/server.js +124 -31
  3. 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 readStateEffect(file = statePath()) {
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* readStateEffect(file);
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((state2) => {
907
- const goal = state2.goals[sessionID];
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 goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
1855
- const budget = goal2.tokenBudget == null ? "" : ` Token usage: ${goal2.tokensUsed}/${goal2.tokenBudget}.`;
1856
- const report2 = `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds.${budget} Evidence: ${goal2.completionEvidence}.`;
1857
- return JSON.stringify({ goal: goal2, completion_report: report2 }, null, 2);
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, messageID2) {
2500
- return `${sessionID}\x00${messageID2}`;
2592
+ function stepKey(sessionID, messageID) {
2593
+ return `${sessionID}\x00${messageID}`;
2501
2594
  }
2502
- async function sendContinuation2(sessionID, prompt, agent) {
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 sendContinuation2(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
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 sendContinuation2(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
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 messageID2 = data.assistantMessageID;
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: messageID2, role: "assistant", time: { completed: event.created } }
2962
+ info: { id: messageID, role: "assistant", time: { completed: event.created } }
2870
2963
  });
2871
- if (!stepTextBuffers.has(stepKey(sessionID, messageID2)))
2872
- stepTextBuffers.set(stepKey(sessionID, messageID2), "");
2873
- latestStepBySession.set(sessionID, { messageID: messageID2, agent, text: "", outputTokens: null, completedAt: event.created });
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2904
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2944
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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.41",
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",