@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/server.js +142 -32
  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}.`;
@@ -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, messageID2) {
2485
- return `${sessionID}\x00${messageID2}`;
2592
+ function stepKey(sessionID, messageID) {
2593
+ return `${sessionID}\x00${messageID}`;
2486
2594
  }
2487
- async function sendContinuation2(sessionID, prompt, agent) {
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 sendContinuation2(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
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 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);
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 messageID2 = data.assistantMessageID;
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: messageID2, role: "assistant", time: { completed: event.created } }
2962
+ info: { id: messageID, role: "assistant", time: { completed: event.created } }
2855
2963
  });
2856
- if (!stepTextBuffers.has(stepKey(sessionID, messageID2)))
2857
- stepTextBuffers.set(stepKey(sessionID, messageID2), "");
2858
- 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 });
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2889
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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 messageID2 = data.assistantMessageID;
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, messageID2)) ?? "";
2929
- stepTextBuffers.delete(stepKey(sessionID, messageID2));
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: messageID2,
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: messageID2,
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
- await handleV2Event(value);
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.40",
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",