@gamflaz/opencode-todolist 1.0.11 → 1.1.0

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.
@@ -1,27 +1,18 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
4
3
  import { homedir, tmpdir } from "node:os";
5
4
  import { join, posix, win32 } from "node:path";
6
- import * as lockfile from "proper-lockfile";
7
- import { TodoRootItemSchema } from "../schemas/todo.js";
5
+ import { eq } from "drizzle-orm";
8
6
  import { TodoTree } from "../todos/todo-tree.js";
9
- import { InvalidTodoDataError } from "./errors.js";
10
- /**
11
- * Per-session todo persistence.
12
- *
13
- * Layout (per session):
14
- * <base>/s_<hash>/todos/todos-<timestamp>.json todo payload (latest wins)
15
- * <base>/s_<hash>/todos/version monotonically increasing version
16
- * <base>/s_<hash>/todos/id hashed session folder name
17
- * <base>/s_<hash>/todos/todos.lock cross-process lock
18
- * <base>/s_<hash>/todos/hint-marker.json hint injection marker
19
- */
7
+ import { getDatabase, getDataVersion as readDataVersion, resetDatabaseForTests, resolveMigrationsFolder, } from "./db.js";
8
+ import { importLegacyJson, mutateSessionTodos, readRevision, replaceSessionTodos, selectTodos, } from "./migrate-json.js";
9
+ import { todoSession } from "./schema.js";
10
+ const FILE_NAME = "todos.db";
20
11
  export class TodoStore {
21
12
  ctx;
22
13
  static SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
23
- static FILE_PREFIX = "todos-";
24
- static FILE_SUFFIX = ".json";
14
+ handle;
15
+ importedBaseDir;
25
16
  constructor(ctx = {}) {
26
17
  this.ctx = ctx;
27
18
  }
@@ -40,18 +31,32 @@ export class TodoStore {
40
31
  return normalized;
41
32
  }
42
33
  /**
43
- * Deterministic cryptographic hash of sessionID.
44
- * Produces an opaque 32-character hexadecimal folder name prefixed with "s_".
45
- * Prevents raw sessionIDs from leaking in the filesystem structure.
34
+ * Storage partition key for a session: the OpenCode session id verbatim.
35
+ *
36
+ * The id is already unique and `normalizeSessionID` already restricts it to
37
+ * `[A-Za-z0-9_-]`, so hashing it bought nothing and cost readability — a raw
38
+ * id can be traced back to a session by eye, a digest cannot.
46
39
  */
47
- static hashSessionID(sessionID) {
40
+ static sessionKey(sessionID) {
41
+ return TodoStore.normalizeSessionID(sessionID);
42
+ }
43
+ /**
44
+ * The key older builds used for this session, kept so their rows still work.
45
+ *
46
+ * A SHA-256 digest cannot be reversed, so a stored hash can never be turned
47
+ * back into an id. It does not need to be: hashing is deterministic, so given
48
+ * the id we recompute the same digest and find the old row, which is what
49
+ * `resolveKey` does. A session that already has rows keeps its old key; only
50
+ * sessions that are new get the readable one.
51
+ */
52
+ static legacyKey(sessionID) {
48
53
  const normalized = TodoStore.normalizeSessionID(sessionID);
49
54
  const digest = createHash("sha256").update(normalized).digest("hex");
50
55
  return `s_${digest.slice(0, 32)}`;
51
56
  }
52
- static async setPrivateMode(path, mode) {
53
- if (process.platform !== "win32")
54
- await chmod(path, mode);
57
+ /** @deprecated Use {@link TodoStore.legacyKey}; kept for existing callers. */
58
+ static hashSessionID(sessionID) {
59
+ return TodoStore.legacyKey(sessionID);
55
60
  }
56
61
  // Platform / directory resolution
57
62
  get env() {
@@ -65,22 +70,11 @@ export class TodoStore {
65
70
  ? win32.join(...parts)
66
71
  : posix.join(...parts);
67
72
  }
68
- /**
69
- * Cross-platform home directory resolution.
70
- *
71
- * `os.homedir()` already adapts to the OS, but it can return an empty string
72
- * in stripped environments, and Windows shells sometimes expose a
73
- * POSIX-style `HOME` (e.g. Git Bash `/c/Users/...`). We therefore prefer the
74
- * canonical variable for the running platform before falling back.
75
- *
76
- * @see {@link https://nodejs.org/api/os.html#oshomedir}
77
- */
78
73
  resolveHomeDir() {
79
74
  const { env, platform } = this;
80
75
  if (this.ctx.home)
81
76
  return this.ctx.home;
82
77
  if (platform === "win32") {
83
- // Windows: USERPROFILE is canonical; HOMEDRIVE+HOMEPATH is the legacy pair.
84
78
  if (env.USERPROFILE)
85
79
  return env.USERPROFILE;
86
80
  if (env.HOMEDRIVE && env.HOMEPATH)
@@ -89,7 +83,6 @@ export class TodoStore {
89
83
  return env.HOME;
90
84
  }
91
85
  else {
92
- // Linux, macOS, BSD, etc.
93
86
  if (env.HOME)
94
87
  return env.HOME;
95
88
  if (env.USERPROFILE)
@@ -102,16 +95,6 @@ export class TodoStore {
102
95
  return tmpdir();
103
96
  }
104
97
  }
105
- /**
106
- * Platform-native base directory for user configuration/data.
107
- *
108
- * - Windows : `%APPDATA%` (fallback `%LOCALAPPDATA%` → `~/AppData/Roaming`)
109
- * - macOS : `~/Library/Application Support`
110
- * - Linux : `$XDG_CONFIG_HOME` (fallback `~/.config`)
111
- *
112
- * `XDG_CONFIG_HOME` is honored first on every platform so users who prefer an
113
- * XDG layout (including those running OpenCode's own convention) can force it.
114
- */
115
98
  resolveConfigHome() {
116
99
  const { env, platform } = this;
117
100
  if (env.XDG_CONFIG_HOME)
@@ -129,32 +112,36 @@ export class TodoStore {
129
112
  return this.joinForPlatform(this.resolveHomeDir(), ".config");
130
113
  }
131
114
  /**
132
- * Base directory that stores per-session todo folders.
115
+ * Base directory holding the database and the legacy session folders.
133
116
  *
134
117
  * Priority:
135
- * 1. `OPENCODE_TODOS_DIR` — explicit plugin override (tests/tooling).
136
- * 2. The user's home directory + `.opencode/sessions`.
118
+ * 1. `OPENCODE_TODOS_DIR` — explicit override for tests and tooling.
119
+ * 2. `<home>/.opencode/sessions`
137
120
  */
138
121
  resolveTodosBaseDir() {
139
122
  if (this.env.OPENCODE_TODOS_DIR)
140
123
  return this.env.OPENCODE_TODOS_DIR;
141
124
  return this.joinForPlatform(this.resolveHomeDir(), ".opencode", "sessions");
142
125
  }
143
- /** Base directory for all sessions (replaces the old `TODOS_DIRECTORY` const). */
144
126
  get todosRoot() {
145
127
  return this.getTodosDirectory();
146
128
  }
147
- // Path getters
129
+ getDatabasePath() {
130
+ return this.joinForPlatform(this.resolveTodosBaseDir(), FILE_NAME);
131
+ }
132
+ /**
133
+ * Legacy per-session directory.
134
+ *
135
+ * No longer written to. Kept because it is still read once to import old JSON
136
+ * and because the TUI watches this directory for filesystem changes.
137
+ */
148
138
  getSessionDirectory(sessionID) {
149
139
  const baseDir = this.resolveTodosBaseDir();
150
140
  const safeSessionID = TodoStore.normalizeSessionID(sessionID);
151
141
  const hashedDir = join(baseDir, TodoStore.hashSessionID(safeSessionID));
152
- // Backward compatibility: if an unhashed legacy directory exists, prefer it
153
- // until it gets migrated or used.
154
142
  const legacyDir = join(baseDir, safeSessionID);
155
- if (!existsSync(hashedDir) && existsSync(legacyDir)) {
143
+ if (!existsSync(hashedDir) && existsSync(legacyDir))
156
144
  return legacyDir;
157
- }
158
145
  return hashedDir;
159
146
  }
160
147
  getTodosDirectory(sessionID) {
@@ -163,279 +150,169 @@ export class TodoStore {
163
150
  return baseDir;
164
151
  return join(this.getSessionDirectory(sessionID), "todos");
165
152
  }
166
- getTodoLockPath(sessionID) {
167
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
168
- return join(this.getTodosDirectory(safeSessionID), "todos.lock");
169
- }
153
+ /**
154
+ * Path of the legacy hint marker.
155
+ *
156
+ * The marker now lives in `todo_session.hint_revision`, so nothing writes
157
+ * here. The path is still exposed because callers may need to locate and
158
+ * clean up a leftover file from an older install.
159
+ */
170
160
  getHintMarkerPath(sessionID) {
171
161
  const safeSessionID = TodoStore.normalizeSessionID(sessionID);
172
162
  return join(this.getTodosDirectory(safeSessionID), "hint-marker.json");
173
163
  }
174
- getSessionIDFilePath(sessionID) {
175
- return join(this.getTodosDirectory(TodoStore.normalizeSessionID(sessionID)), "id");
164
+ // Connection
165
+ database() {
166
+ if (!this.handle) {
167
+ this.handle = getDatabase(this.getDatabasePath());
168
+ }
169
+ // Import legacy JSON once per base directory for this process.
170
+ const baseDir = this.resolveTodosBaseDir();
171
+ if (this.importedBaseDir !== baseDir) {
172
+ importLegacyJson(this.handle, baseDir);
173
+ this.importedBaseDir = baseDir;
174
+ }
175
+ return this.handle;
176
176
  }
177
- getVersionFilePath(sessionID) {
178
- return join(this.getTodosDirectory(TodoStore.normalizeSessionID(sessionID)), "version");
177
+ key(sessionID) {
178
+ return TodoStore.sessionKey(sessionID);
179
179
  }
180
- getTodoFilePath(sessionID) {
181
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
182
- return (this.listSessionFiles(safeSessionID)[0]?.path ??
183
- join(this.getTodosDirectory(safeSessionID), `todos-${Date.now()}.json`));
180
+ /**
181
+ * Resolves the key a session's rows currently live under.
182
+ *
183
+ * Prefers the real session id. A session first written by an older build
184
+ * still sits under its hashed key, so that is checked as a fallback — without
185
+ * it, upgrading would look like every todo list had vanished.
186
+ */
187
+ resolveKey(sessionID) {
188
+ const handle = this.database();
189
+ const key = this.key(sessionID);
190
+ const found = handle.db
191
+ .select({ sessionKey: todoSession.sessionKey })
192
+ .from(todoSession)
193
+ .where(eq(todoSession.sessionKey, key))
194
+ .get();
195
+ if (found)
196
+ return key;
197
+ const legacy = TodoStore.legacyKey(sessionID);
198
+ const legacyFound = handle.db
199
+ .select({ sessionKey: todoSession.sessionKey })
200
+ .from(todoSession)
201
+ .where(eq(todoSession.sessionKey, legacy))
202
+ .get();
203
+ return legacyFound ? legacy : key;
184
204
  }
185
205
  // Public read API
206
+ /**
207
+ * Reads a session's list.
208
+ *
209
+ * Synchronous at its core because `bun:sqlite` is synchronous; the `async`
210
+ * signature is kept so existing callers do not need to change.
211
+ */
186
212
  async readTodos(sessionID) {
187
- const filePath = this.getTodoFilePath(sessionID);
188
- try {
189
- return TodoStore.parseTodoFile(await readFile(filePath, "utf8"));
190
- }
191
- catch (error) {
192
- return this.recoverFromReadError(error);
193
- }
213
+ return this.readTodosSync(sessionID);
194
214
  }
195
215
  readTodosSync(sessionID) {
196
- const filePath = this.getTodoFilePath(sessionID);
197
- try {
198
- return TodoStore.parseTodoFile(readFileSync(filePath, "utf8"));
199
- }
200
- catch (error) {
201
- return this.recoverFromReadError(error);
202
- }
216
+ return selectTodos(this.database().db, this.resolveKey(sessionID));
203
217
  }
204
218
  readTodoData(sessionID) {
205
- try {
206
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
207
- const raw = readFileSync(this.getTodoFilePath(safeSessionID), "utf8");
208
- const parsed = JSON.parse(raw);
209
- if (!parsed || typeof parsed !== "object")
210
- return null;
211
- const data = parsed;
212
- const todos = (Array.isArray(data.todos)
213
- ? data.todos
214
- : Array.isArray(parsed)
215
- ? parsed
216
- : []);
217
- const legacyVersion = typeof data.version === "number" && Number.isSafeInteger(data.version)
218
- ? data.version
219
- : 0;
220
- const legacyCreatedAt = typeof data.createdAt === "string" ? data.createdAt : undefined;
221
- const legacyUpdatedAt = typeof data.updatedAt === "string" ? data.updatedAt : undefined;
222
- const updated_at = typeof data.updated_at === "string"
223
- ? data.updated_at
224
- : (legacyUpdatedAt ?? new Date().toISOString());
225
- const created_at = typeof data.created_at === "string"
226
- ? data.created_at
227
- : (legacyCreatedAt ?? legacyUpdatedAt ?? updated_at);
228
- return {
229
- version: this.readVersionFile(safeSessionID) ?? legacyVersion,
230
- todos,
231
- created_at,
232
- updated_at,
233
- };
234
- }
235
- catch {
219
+ const handle = this.database();
220
+ const key = this.resolveKey(sessionID);
221
+ const session = handle.db
222
+ .select()
223
+ .from(todoSession)
224
+ .where(eq(todoSession.sessionKey, key))
225
+ .get();
226
+ if (!session)
236
227
  return null;
228
+ const todos = selectTodos(handle.db, key);
229
+ return {
230
+ version: session.revision,
231
+ todos,
232
+ created_at: session.createdAt,
233
+ updated_at: session.updatedAt,
234
+ };
235
+ }
236
+ /** Revision counter, used by the TUI to detect changes cheaply. */
237
+ getRevision(sessionID) {
238
+ return readRevision(this.database(), this.resolveKey(sessionID));
239
+ }
240
+ /**
241
+ * Reads the hint marker value and, when it is stale, records the new
242
+ * revision. Returns the previous revision so the caller can decide.
243
+ */
244
+ takeHintRevision(sessionID, currentRevision) {
245
+ const handle = this.database();
246
+ const key = this.resolveKey(sessionID);
247
+ const session = handle.db
248
+ .select({ hintRevision: todoSession.hintRevision })
249
+ .from(todoSession)
250
+ .where(eq(todoSession.sessionKey, key))
251
+ .get();
252
+ const previous = session?.hintRevision ?? 0;
253
+ if (previous !== currentRevision) {
254
+ handle.db
255
+ .update(todoSession)
256
+ .set({ hintRevision: currentRevision })
257
+ .where(eq(todoSession.sessionKey, key))
258
+ .run();
237
259
  }
260
+ return previous;
261
+ }
262
+ /** Cross-process change signal for the TUI watcher. */
263
+ getDataVersion() {
264
+ return readDataVersion(this.database().sqlite);
265
+ }
266
+ migrationsFolder() {
267
+ return resolveMigrationsFolder();
238
268
  }
239
269
  // Public write API
240
270
  async writeTodos(sessionID, todos) {
241
271
  await this.updateTodos(sessionID, () => todos);
242
272
  }
243
273
  async updateTodos(sessionID, update) {
244
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
245
- const baseDir = this.getTodosDirectory();
246
- const legacyDir = join(baseDir, safeSessionID);
247
- const targetDir = join(baseDir, TodoStore.hashSessionID(safeSessionID));
248
- // Migrate legacy plaintext directories to hashed ones.
249
- if (existsSync(legacyDir) && !existsSync(targetDir)) {
250
- try {
251
- await rename(legacyDir, targetDir);
252
- }
253
- catch {
254
- // Fallback: if rename fails (e.g. cross-device), continue using legacyDir
255
- }
256
- }
257
- const sessionRoot = this.getSessionDirectory(safeSessionID);
258
- const sessionDir = this.getTodosDirectory(safeSessionID);
259
- await mkdir(sessionDir, { recursive: true });
260
- await TodoStore.setPrivateMode(sessionRoot, 0o700);
261
- await TodoStore.setPrivateMode(sessionDir, 0o700);
262
- await this.migrateFlatTodoFiles(sessionRoot, sessionDir);
263
- const release = await lockfile.lock(this.getTodoFilePath(safeSessionID), {
264
- realpath: false,
265
- lockfilePath: this.getTodoLockPath(safeSessionID),
266
- retries: { retries: 10, minTimeout: 10, maxTimeout: 250 },
267
- stale: 10_000,
268
- });
269
- try {
270
- const filePath = this.getTodoFilePath(safeSessionID);
271
- let current;
272
- try {
273
- current = await this.readTodos(safeSessionID);
274
- }
275
- catch (error) {
276
- if (!(error instanceof InvalidTodoDataError))
277
- throw error;
278
- // Archive the corrupt file and start from an empty list.
279
- const archivedPath = `${filePath}.corrupt-${Date.now()}`;
280
- const archived = await rename(filePath, archivedPath).then(() => true, () => false);
281
- if (archived)
282
- await TodoStore.setPrivateMode(archivedPath, 0o600);
283
- current = [];
284
- }
274
+ const handle = this.database();
275
+ const key = this.resolveKey(sessionID);
276
+ // One transaction covers read, transform, and write. Splitting them would
277
+ // allow a lost update when the tool process and the widget process write
278
+ // concurrently: both could read revision N and then both write N+1.
279
+ return mutateSessionTodos(handle, key, (current) => {
285
280
  const result = new TodoTree(update(current)).validateAndNormalize();
286
281
  if (!result.valid) {
282
+ // Throwing inside the transaction rolls it back, so an invalid list
283
+ // never reaches disk and never bumps the revision.
287
284
  throw new Error(`[todo store validation failed] ${result.error}`);
288
285
  }
289
- const existingData = this.readTodoData(safeSessionID);
290
- const now = new Date().toISOString();
291
- const nextVersion = (existingData?.version ?? 0) + 1;
292
- const payload = {
293
- created_at: existingData?.created_at ?? now,
294
- updated_at: now,
295
- todos: result.data,
296
- };
297
- await this.writeJsonAtomically(filePath, payload);
298
- await this.writeTextAtomically(this.getVersionFilePath(safeSessionID), `${nextVersion}\n`);
299
- await this.writeTextAtomically(this.getSessionIDFilePath(safeSessionID), `${TodoStore.hashSessionID(safeSessionID)}\n`);
300
- await rm(join(sessionDir, "session.json"), { force: true });
301
- await rm(join(sessionRoot, "session.json"), { force: true });
302
286
  return result.data;
303
- }
304
- finally {
305
- await release();
306
- }
287
+ }).todos;
307
288
  }
308
- // Internals
309
- scanTodoFiles(directory, prefix) {
310
- const suffix = TodoStore.FILE_SUFFIX;
311
- return readdirSync(directory)
312
- .flatMap((name) => {
313
- if (!name.startsWith(prefix) || !name.endsWith(suffix))
314
- return [];
315
- const timestamp = Number(name.slice(prefix.length, -suffix.length));
316
- if (!Number.isSafeInteger(timestamp) || timestamp < 0)
317
- return [];
318
- return [{ path: join(directory, name), timestamp }];
319
- })
320
- .sort((left, right) => right.timestamp - left.timestamp);
289
+ /** Replaces a list without running the tree validation (import path). */
290
+ replaceTodosSync(sessionID, todos) {
291
+ replaceSessionTodos(this.database(), this.resolveKey(sessionID), todos);
321
292
  }
322
- listSessionFiles(sessionID) {
323
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
324
- const sessionDirs = [
325
- this.getTodosDirectory(safeSessionID),
326
- this.getSessionDirectory(safeSessionID),
327
- ].filter((directory, index, all) => all.indexOf(directory) === index);
328
- for (const sessionDir of sessionDirs) {
329
- try {
330
- const files = this.scanTodoFiles(sessionDir, TodoStore.FILE_PREFIX);
331
- if (files.length > 0)
332
- return files;
333
- }
334
- catch (error) {
335
- if (!TodoStore.isMissingFile(error))
336
- throw error;
337
- }
338
- }
339
- // Oldest layout: flat files in the base directory, `todos-<sessionID>-<ts>.json`.
340
- try {
341
- return this.scanTodoFiles(this.getTodosDirectory(), `${TodoStore.FILE_PREFIX}${safeSessionID}-`);
342
- }
343
- catch (error) {
344
- if (TodoStore.isMissingFile(error))
345
- return [];
346
- throw error;
347
- }
348
- }
349
- readVersionFile(sessionID) {
350
- const safeSessionID = TodoStore.normalizeSessionID(sessionID);
351
- const paths = [
352
- this.getVersionFilePath(safeSessionID),
353
- join(this.getSessionDirectory(safeSessionID), "version"),
354
- ];
355
- for (const path of paths) {
356
- try {
357
- const version = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
358
- if (Number.isSafeInteger(version) && version >= 0)
359
- return version;
360
- }
361
- catch {
362
- // try the next candidate
363
- }
364
- }
365
- return null;
366
- }
367
- static parseTodoFile(raw) {
368
- try {
369
- const parsed = JSON.parse(raw);
370
- const list = Array.isArray(parsed)
371
- ? parsed
372
- : typeof parsed === "object" && parsed !== null && "todos" in parsed
373
- ? parsed.todos
374
- : undefined;
375
- if (!Array.isArray(list))
376
- throw new Error("todo list is missing");
377
- const result = TodoRootItemSchema.array().safeParse(list);
378
- if (!result.success) {
379
- throw new Error(result.error.issues.map((i) => i.message).join(", "));
380
- }
381
- return result.data;
382
- }
383
- catch (err) {
384
- throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${err instanceof Error ? err.message : String(err)}`);
385
- }
386
- }
387
- /** Shared error policy for readTodos / readTodosSync. */
388
- recoverFromReadError(error) {
389
- if (TodoStore.isMissingFile(error))
390
- return [];
391
- if (error instanceof InvalidTodoDataError)
392
- throw error;
393
- throw new Error(`[todo store read failed] unable to read todo storage: ${error instanceof Error ? error.message : String(error)}`);
394
- }
395
- async writeTextAtomically(filePath, content) {
396
- const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
397
- try {
398
- await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
399
- await rename(temporary, filePath);
400
- }
401
- finally {
402
- await rm(temporary, { force: true });
403
- }
404
- }
405
- async writeJsonAtomically(filePath, data) {
406
- await this.writeTextAtomically(filePath, `${JSON.stringify(data, null, 2)}\n`);
407
- }
408
- async migrateFlatTodoFiles(sessionRoot, todosDir) {
409
- let names;
410
- try {
411
- names = readdirSync(sessionRoot);
412
- }
413
- catch (error) {
414
- if (TodoStore.isMissingFile(error))
415
- return;
416
- throw error;
417
- }
418
- const movable = new Set([
419
- "id",
420
- "version",
421
- "todos.lock",
422
- "hint-marker.json",
423
- ]);
424
- for (const name of names) {
425
- const isTodoFile = name.startsWith(TodoStore.FILE_PREFIX) &&
426
- name.endsWith(TodoStore.FILE_SUFFIX);
427
- if (!isTodoFile && !movable.has(name))
428
- continue;
429
- const to = join(todosDir, name);
430
- if (existsSync(to))
431
- continue;
432
- await rename(join(sessionRoot, name), to).catch(() => { });
433
- }
293
+ /**
294
+ * Drops the cached connection so the next call reopens from scratch.
295
+ *
296
+ * Only for tests: the store deliberately caches one connection per process
297
+ * (opening applies migrations and sets up WAL, which is not free). A test
298
+ * that points `OPENCODE_TODOS_DIR` at a new directory must reset, otherwise
299
+ * the previous connection keeps servicing the old, unlinked file.
300
+ */
301
+ resetForTests() {
302
+ this.handle = undefined;
303
+ this.importedBaseDir = undefined;
304
+ // The connection itself is cached in `db.ts`, so closing it there is what
305
+ // actually releases the old file descriptor.
306
+ resetDatabaseForTests();
434
307
  }
435
308
  }
436
- // Module-level singletons / functional helpers for backward-compatibility
309
+ // Module-level singleton plus functional helpers for backward compatibility.
437
310
  const defaultStore = new TodoStore();
438
311
  export const getTodosDirectory = (sessionID) => defaultStore.getTodosDirectory(sessionID);
312
+ export const getDatabasePath = () => defaultStore.getDatabasePath();
313
+ export const sessionKey = (sessionID) => TodoStore.sessionKey(sessionID);
314
+ export const legacySessionKey = (sessionID) => TodoStore.legacyKey(sessionID);
315
+ /** @deprecated Use {@link legacySessionKey}; the hash is no longer the key. */
439
316
  export const hashSessionID = (sessionID) => TodoStore.hashSessionID(sessionID);
440
317
  export const readTodos = (sessionID) => defaultStore.readTodos(sessionID);
441
318
  export const readTodosSync = (sessionID) => defaultStore.readTodosSync(sessionID);
@@ -443,3 +320,7 @@ export const writeTodos = (sessionID, todos) => defaultStore.writeTodos(sessionI
443
320
  export const updateTodos = (sessionID, update) => defaultStore.updateTodos(sessionID, update);
444
321
  export const readTodoData = (sessionID) => defaultStore.readTodoData(sessionID);
445
322
  export const getHintMarkerPath = (sessionID) => defaultStore.getHintMarkerPath(sessionID);
323
+ export const getRevision = (sessionID) => defaultStore.getRevision(sessionID);
324
+ export const takeHintRevision = (sessionID, revision) => defaultStore.takeHintRevision(sessionID, revision);
325
+ export const getDataVersion = () => defaultStore.getDataVersion();
326
+ export const resetTodoStoreForTests = () => defaultStore.resetForTests();
@@ -1,4 +1,4 @@
1
- import type { TodoChildern, TodoRootItem } from "../schemas/todo";
1
+ import type { TodoChildren, TodoRootItem } from "../schemas/todo";
2
2
  export declare const DEFAULT_STALE_MS: number;
3
3
  export declare const CONTEXT_STALE_MS: number;
4
4
  export declare const TODO_HINT: string;
@@ -7,8 +7,19 @@ export interface ConsumeTodoHintOptions {
7
7
  staleMs?: number;
8
8
  now?: number;
9
9
  }
10
+ /**
11
+ * Returns the resume hint at most once per stored revision.
12
+ *
13
+ * The "already hinted" marker used to be a `hint-marker.json` file next to the
14
+ * todo payload. It now lives in `todo_session.hint_revision`, so the decision is
15
+ * a single row and cannot drift from the data it describes.
16
+ *
17
+ * The hint is suppressed when the list has no open item, or when the stored
18
+ * write is older than `staleMs` — a resumed session should not be told about
19
+ * work the user abandoned long ago.
20
+ */
10
21
  export declare function consumeTodoHint(sessionID: string, options?: ConsumeTodoHintOptions): string | undefined;
11
- type Leaf = TodoRootItem | TodoChildern;
22
+ type Leaf = TodoRootItem | TodoChildren;
12
23
  export declare function flattenLeaves(todos: readonly TodoRootItem[]): Leaf[];
13
24
  export declare function findNextTodo(todos: readonly TodoRootItem[]): Leaf | undefined;
14
25
  export declare function findStuckParent(todos: readonly TodoRootItem[]): TodoRootItem | undefined;
@@ -1,5 +1,4 @@
1
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { getHintMarkerPath, readTodoData, TodoStore } from "../storage/todo-store.js";
1
+ import { readTodoData, takeHintRevision, TodoStore } from "../storage/todo-store.js";
3
2
  import { TodoTree } from "./todo-tree.js";
4
3
  export const DEFAULT_STALE_MS = 30 * 60 * 1000; // 30 minutes (prompt fallback)
5
4
  export const CONTEXT_STALE_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -17,11 +16,17 @@ function isTaskActive(task) {
17
16
  export function isAnyTodoActive(todos) {
18
17
  return todos.some(isTaskActive);
19
18
  }
20
- function atomicWrite(file, data) {
21
- const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
22
- writeFileSync(tmp, data, "utf8");
23
- renameSync(tmp, file);
24
- }
19
+ /**
20
+ * Returns the resume hint at most once per stored revision.
21
+ *
22
+ * The "already hinted" marker used to be a `hint-marker.json` file next to the
23
+ * todo payload. It now lives in `todo_session.hint_revision`, so the decision is
24
+ * a single row and cannot drift from the data it describes.
25
+ *
26
+ * The hint is suppressed when the list has no open item, or when the stored
27
+ * write is older than `staleMs` — a resumed session should not be told about
28
+ * work the user abandoned long ago.
29
+ */
25
30
  export function consumeTodoHint(sessionID, options) {
26
31
  try {
27
32
  if (!sessionID)
@@ -39,19 +44,11 @@ export function consumeTodoHint(sessionID, options) {
39
44
  if (Number.isFinite(updatedAt) && now - updatedAt > staleMs) {
40
45
  return undefined;
41
46
  }
42
- const markerFile = getHintMarkerPath(safeSessionID);
43
- let hintedVersion = -1;
44
- try {
45
- const marker = JSON.parse(readFileSync(markerFile, "utf8"));
46
- if (typeof marker?.version === "number")
47
- hintedVersion = marker.version;
48
- }
49
- catch {
50
- // Marker missing or corrupt
51
- }
52
- if (hintedVersion === state.version)
47
+ // Atomic: reads the previous marker and records this revision in one step,
48
+ // so two rapid calls cannot both emit the hint.
49
+ const previous = takeHintRevision(safeSessionID, state.version);
50
+ if (previous === state.version)
53
51
  return undefined;
54
- atomicWrite(markerFile, JSON.stringify({ version: state.version }));
55
52
  return TODO_HINT;
56
53
  }
57
54
  catch {