@prevalentware/opencode-goal-plugin 0.1.33 → 0.1.35

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 +5 -1
  2. package/dist/server.js +137 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -225,7 +225,11 @@ If `XDG_DATA_HOME` is not set, the default is:
225
225
 
226
226
  Set `OPENCODE_GOAL_STATE_PATH` to use a custom file.
227
227
 
228
- The state file is written atomically with owner-only permissions when the host filesystem supports it. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata.
228
+ The state file is written atomically through a same-directory temp file: the final path is only ever replaced by a fully-flushed file, so after a crash the state is the previous or the new valid version, never a torn one. The file is created with owner-only permissions where the host filesystem supports them, and the temp name is a random UUID opened exclusively so concurrent writers cannot collide.
229
+
230
+ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden power loss on macOS/APFS is not an absolute durability guarantee; where the platform cannot fsync the parent directory, a crash may leave the old or the new state file (both valid), never a partially-written one. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata.
231
+
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.
229
233
 
230
234
  ## Credits
231
235
 
package/dist/server.js CHANGED
@@ -3,11 +3,120 @@
3
3
  import { z } from "zod";
4
4
 
5
5
  // src/state.ts
6
- import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6
+ import { mkdir, readFile } from "fs/promises";
7
7
  import { homedir } from "os";
8
- import { dirname, join } from "path";
8
+ import { dirname as dirname2, join } from "path";
9
9
  import { Data, Effect, Schema } from "effect";
10
10
 
11
+ // src/atomic-write.ts
12
+ import { randomUUID } from "crypto";
13
+ import { chmod, open, rename, unlink } from "fs/promises";
14
+ import { dirname } from "path";
15
+ function isUnsupportedSyncDirError(error, platform) {
16
+ const code = error?.code;
17
+ return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EISDIR" || platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBADF");
18
+ }
19
+ function isTransientRenameError(error, platform) {
20
+ if (platform !== "win32")
21
+ return false;
22
+ const code = error?.code;
23
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY";
24
+ }
25
+ async function bestEffort(action) {
26
+ try {
27
+ await action();
28
+ } catch {}
29
+ }
30
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
+ var defaultDirOpenOps = {
32
+ async open(dir, flags) {
33
+ const handle = await open(dir, flags);
34
+ return {
35
+ sync: () => handle.sync(),
36
+ close: () => handle.close()
37
+ };
38
+ }
39
+ };
40
+ async function syncDirectory(dir, ops = defaultDirOpenOps, platform = process.platform) {
41
+ let fsHandle = null;
42
+ try {
43
+ const handle = await ops.open(dir, "r");
44
+ fsHandle = handle;
45
+ await handle.sync();
46
+ } catch (error) {
47
+ if (!isUnsupportedSyncDirError(error, platform))
48
+ throw error;
49
+ } finally {
50
+ const cleanupHandle = fsHandle;
51
+ if (cleanupHandle)
52
+ await bestEffort(() => cleanupHandle.close());
53
+ }
54
+ }
55
+ var defaultAtomicWriteOps = {
56
+ platform: process.platform,
57
+ async open(path, flags, mode) {
58
+ const handle = await open(path, flags, mode);
59
+ return {
60
+ write: (data) => handle.writeFile(data),
61
+ sync: () => handle.sync(),
62
+ close: () => handle.close()
63
+ };
64
+ },
65
+ rename,
66
+ chmod,
67
+ unlink,
68
+ syncDir: (dir, platform) => syncDirectory(dir, defaultDirOpenOps, platform),
69
+ sleep: defaultSleep
70
+ };
71
+ var RENAME_ATTEMPTS = 3;
72
+ var RENAME_RETRY_DELAY_MS = 20;
73
+ async function renameWithRetry(ops, from, to) {
74
+ let attempt = 0;
75
+ for (;; ) {
76
+ try {
77
+ await ops.rename(from, to);
78
+ return;
79
+ } catch (error) {
80
+ attempt += 1;
81
+ if (!isTransientRenameError(error, ops.platform) || attempt >= RENAME_ATTEMPTS)
82
+ throw error;
83
+ const delay = RENAME_RETRY_DELAY_MS * attempt;
84
+ await ops.sleep(delay);
85
+ }
86
+ }
87
+ }
88
+ async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
89
+ const tmp = `${file}.${randomUUID()}.tmp`;
90
+ let handle = null;
91
+ let created = false;
92
+ let renamed = false;
93
+ try {
94
+ handle = await ops.open(tmp, "wx", 384);
95
+ created = true;
96
+ await handle.write(data);
97
+ await handle.sync();
98
+ await handle.close();
99
+ handle = null;
100
+ await renameWithRetry(ops, tmp, file);
101
+ renamed = true;
102
+ await bestEffort(() => ops.chmod(file, 384));
103
+ try {
104
+ await ops.syncDir(dirname(file), ops.platform);
105
+ } catch (error) {
106
+ if (!isUnsupportedSyncDirError(error, ops.platform))
107
+ throw error;
108
+ }
109
+ } catch (error) {
110
+ const cleanupHandle = handle;
111
+ if (cleanupHandle)
112
+ await bestEffort(() => cleanupHandle.close());
113
+ if (created && !renamed)
114
+ await bestEffort(() => ops.unlink(tmp));
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ // src/state.ts
11
120
  class StateReadError extends Data.TaggedError("StateReadError") {
12
121
  }
13
122
 
@@ -101,15 +210,36 @@ function isMissingStateFile(error) {
101
210
  function mutableState(state) {
102
211
  return JSON.parse(JSON.stringify(state));
103
212
  }
213
+ var warnedEmptyStatePaths = new Set;
214
+ function isStatePadding(character) {
215
+ return character === "\x00" || character.trim() === "";
216
+ }
217
+ function parseStateText(raw, file) {
218
+ let start = 0;
219
+ let end = raw.length;
220
+ while (start < end && isStatePadding(raw[start]))
221
+ start += 1;
222
+ while (end > start && isStatePadding(raw[end - 1]))
223
+ end -= 1;
224
+ const content = raw.slice(start, end);
225
+ if (content)
226
+ return JSON.parse(content);
227
+ if (!warnedEmptyStatePaths.has(file)) {
228
+ warnedEmptyStatePaths.add(file);
229
+ console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`);
230
+ }
231
+ return emptyState();
232
+ }
104
233
  function decodeState(value) {
105
234
  return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
106
235
  }
107
236
  function readStateEffect() {
237
+ const file = statePath();
108
238
  return Effect.tryPromise({
109
- try: () => readFile(statePath(), "utf8"),
239
+ try: () => readFile(file, "utf8"),
110
240
  catch: (cause) => new StateReadError({ cause })
111
241
  }).pipe(Effect.flatMap((raw) => Effect.try({
112
- try: () => JSON.parse(raw),
242
+ try: () => parseStateText(raw, file),
113
243
  catch: (cause) => new StateDecodeError({ cause })
114
244
  })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
115
245
  }
@@ -117,14 +247,9 @@ function writeStateEffect(state) {
117
247
  return Effect.tryPromise({
118
248
  try: async () => {
119
249
  const file = statePath();
120
- await mkdir(dirname(file), { recursive: true, mode: 448 });
121
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
122
- await writeFile(tmp, JSON.stringify(state, null, 2) + `
123
- `, { mode: 384 });
124
- await rename(tmp, file);
125
- await chmod(file, 384).catch(() => {
126
- return;
127
- });
250
+ await mkdir(dirname2(file), { recursive: true, mode: 448 });
251
+ await atomicWriteFile(file, JSON.stringify(state, null, 2) + `
252
+ `);
128
253
  },
129
254
  catch: (cause) => new StateWriteError({ cause })
130
255
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
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",