@gamflaz/opencode-todolist 1.0.4 → 1.0.5

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.
@@ -0,0 +1,445 @@
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";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join, posix, win32 } from "node:path";
6
+ import * as lockfile from "proper-lockfile";
7
+ import { TodoRootItemSchema } from "../schemas/todo";
8
+ import { TodoTree } from "../todos/todo-tree";
9
+ import { InvalidTodoDataError } from "./errors";
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
+ */
20
+ export class TodoStore {
21
+ ctx;
22
+ static SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
23
+ static FILE_PREFIX = "todos-";
24
+ static FILE_SUFFIX = ".json";
25
+ constructor(ctx = {}) {
26
+ this.ctx = ctx;
27
+ }
28
+ // Static helpers
29
+ static isMissingFile(error) {
30
+ return (typeof error === "object" &&
31
+ error !== null &&
32
+ "code" in error &&
33
+ error.code === "ENOENT");
34
+ }
35
+ static normalizeSessionID(sessionID) {
36
+ const normalized = sessionID.trim();
37
+ if (!TodoStore.SESSION_ID_PATTERN.test(normalized)) {
38
+ throw new Error("[todo store] invalid session ID");
39
+ }
40
+ return normalized;
41
+ }
42
+ /**
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.
46
+ */
47
+ static hashSessionID(sessionID) {
48
+ const normalized = TodoStore.normalizeSessionID(sessionID);
49
+ const digest = createHash("sha256").update(normalized).digest("hex");
50
+ return `s_${digest.slice(0, 32)}`;
51
+ }
52
+ static async setPrivateMode(path, mode) {
53
+ if (process.platform !== "win32")
54
+ await chmod(path, mode);
55
+ }
56
+ // Platform / directory resolution
57
+ get env() {
58
+ return this.ctx.env ?? process.env;
59
+ }
60
+ get platform() {
61
+ return this.ctx.platform ?? process.platform;
62
+ }
63
+ joinForPlatform(...parts) {
64
+ return this.platform === "win32"
65
+ ? win32.join(...parts)
66
+ : posix.join(...parts);
67
+ }
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
+ resolveHomeDir() {
79
+ const { env, platform } = this;
80
+ if (this.ctx.home)
81
+ return this.ctx.home;
82
+ if (platform === "win32") {
83
+ // Windows: USERPROFILE is canonical; HOMEDRIVE+HOMEPATH is the legacy pair.
84
+ if (env.USERPROFILE)
85
+ return env.USERPROFILE;
86
+ if (env.HOMEDRIVE && env.HOMEPATH)
87
+ return `${env.HOMEDRIVE}${env.HOMEPATH}`;
88
+ if (env.HOME)
89
+ return env.HOME;
90
+ }
91
+ else {
92
+ // Linux, macOS, BSD, etc.
93
+ if (env.HOME)
94
+ return env.HOME;
95
+ if (env.USERPROFILE)
96
+ return env.USERPROFILE;
97
+ }
98
+ try {
99
+ return homedir();
100
+ }
101
+ catch {
102
+ return tmpdir();
103
+ }
104
+ }
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
+ resolveConfigHome() {
116
+ const { env, platform } = this;
117
+ if (env.XDG_CONFIG_HOME)
118
+ return env.XDG_CONFIG_HOME;
119
+ if (platform === "win32") {
120
+ if (env.APPDATA)
121
+ return env.APPDATA;
122
+ if (env.LOCALAPPDATA)
123
+ return env.LOCALAPPDATA;
124
+ return this.joinForPlatform(this.resolveHomeDir(), "AppData", "Roaming");
125
+ }
126
+ if (platform === "darwin") {
127
+ return this.joinForPlatform(this.resolveHomeDir(), "Library", "Application Support");
128
+ }
129
+ return this.joinForPlatform(this.resolveHomeDir(), ".config");
130
+ }
131
+ /**
132
+ * Base directory that stores per-session todo folders.
133
+ *
134
+ * Priority:
135
+ * 1. `OPENCODE_TODOS_DIR` — explicit plugin override (tests/tooling).
136
+ * 2. The user's home directory + `.opencode/sessions`.
137
+ */
138
+ resolveTodosBaseDir() {
139
+ if (this.env.OPENCODE_TODOS_DIR)
140
+ return this.env.OPENCODE_TODOS_DIR;
141
+ return this.joinForPlatform(this.resolveHomeDir(), ".opencode", "sessions");
142
+ }
143
+ /** Base directory for all sessions (replaces the old `TODOS_DIRECTORY` const). */
144
+ get todosRoot() {
145
+ return this.getTodosDirectory();
146
+ }
147
+ // Path getters
148
+ getSessionDirectory(sessionID) {
149
+ const baseDir = this.resolveTodosBaseDir();
150
+ const safeSessionID = TodoStore.normalizeSessionID(sessionID);
151
+ 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
+ const legacyDir = join(baseDir, safeSessionID);
155
+ if (!existsSync(hashedDir) && existsSync(legacyDir)) {
156
+ return legacyDir;
157
+ }
158
+ return hashedDir;
159
+ }
160
+ getTodosDirectory(sessionID) {
161
+ const baseDir = this.resolveTodosBaseDir();
162
+ if (!sessionID)
163
+ return baseDir;
164
+ return join(this.getSessionDirectory(sessionID), "todos");
165
+ }
166
+ getTodoLockPath(sessionID) {
167
+ const safeSessionID = TodoStore.normalizeSessionID(sessionID);
168
+ return join(this.getTodosDirectory(safeSessionID), "todos.lock");
169
+ }
170
+ getHintMarkerPath(sessionID) {
171
+ const safeSessionID = TodoStore.normalizeSessionID(sessionID);
172
+ return join(this.getTodosDirectory(safeSessionID), "hint-marker.json");
173
+ }
174
+ getSessionIDFilePath(sessionID) {
175
+ return join(this.getTodosDirectory(TodoStore.normalizeSessionID(sessionID)), "id");
176
+ }
177
+ getVersionFilePath(sessionID) {
178
+ return join(this.getTodosDirectory(TodoStore.normalizeSessionID(sessionID)), "version");
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`));
184
+ }
185
+ // Public read API
186
+ 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
+ }
194
+ }
195
+ 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
+ }
203
+ }
204
+ 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 {
236
+ return null;
237
+ }
238
+ }
239
+ // Public write API
240
+ async writeTodos(sessionID, todos) {
241
+ await this.updateTodos(sessionID, () => todos);
242
+ }
243
+ 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
+ }
285
+ const result = new TodoTree(update(current)).validateAndNormalize();
286
+ if (!result.valid) {
287
+ throw new Error(`[todo store validation failed] ${result.error}`);
288
+ }
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
+ return result.data;
303
+ }
304
+ finally {
305
+ await release();
306
+ }
307
+ }
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);
321
+ }
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
+ }
434
+ }
435
+ }
436
+ // Module-level singletons / functional helpers for backward-compatibility
437
+ const defaultStore = new TodoStore();
438
+ export const getTodosDirectory = (sessionID) => defaultStore.getTodosDirectory(sessionID);
439
+ export const hashSessionID = (sessionID) => TodoStore.hashSessionID(sessionID);
440
+ export const readTodos = (sessionID) => defaultStore.readTodos(sessionID);
441
+ export const readTodosSync = (sessionID) => defaultStore.readTodosSync(sessionID);
442
+ export const writeTodos = (sessionID, todos) => defaultStore.writeTodos(sessionID, todos);
443
+ export const updateTodos = (sessionID, update) => defaultStore.updateTodos(sessionID, update);
444
+ export const readTodoData = (sessionID) => defaultStore.readTodoData(sessionID);
445
+ export const getHintMarkerPath = (sessionID) => defaultStore.getHintMarkerPath(sessionID);
@@ -0,0 +1,21 @@
1
+ import type { TodoStatus } from "../schemas/status";
2
+ import type { TodoRootItem } from "../schemas/todo";
3
+ export interface TodoDisplayItem {
4
+ id: number;
5
+ content: string;
6
+ icon: string;
7
+ status: TodoStatus;
8
+ depth: number;
9
+ /** Technical context/evidence; the TUI reveals it on demand (click). */
10
+ note?: string;
11
+ }
12
+ export interface TodoDisplaySummary {
13
+ header: string;
14
+ completed: number;
15
+ total: number;
16
+ items: TodoDisplayItem[];
17
+ }
18
+ export declare function getStatusIcon(status: TodoStatus): string;
19
+ export declare function hasOpenTodos(items: TodoDisplayItem[]): boolean;
20
+ export declare function getTodoDisplaySummary(todos: TodoRootItem[]): TodoDisplaySummary | null;
21
+ export declare function formatTodoProgress(todos: TodoRootItem[]): string | null;
@@ -0,0 +1,53 @@
1
+ import { TodoTree } from "./todo-tree";
2
+ export function getStatusIcon(status) {
3
+ switch (status) {
4
+ case "completed":
5
+ return "✓";
6
+ case "in_progress":
7
+ return "⦿";
8
+ case "error":
9
+ return "!";
10
+ case "cancelled":
11
+ return "✗";
12
+ case "pending":
13
+ default:
14
+ return "○";
15
+ }
16
+ }
17
+ export function hasOpenTodos(items) {
18
+ return items.some((item) => TodoTree.isOpenStatus(item.status));
19
+ }
20
+ export function getTodoDisplaySummary(todos) {
21
+ if (!todos || todos.length === 0)
22
+ return null;
23
+ const stats = new TodoTree(todos).calculateStats();
24
+ const completed = stats.completedSubTasks;
25
+ const total = stats.totalSubTasks;
26
+ const header = `Todos (${completed}/${total})`;
27
+ const items = [];
28
+ for (const parent of todos) {
29
+ items.push({
30
+ id: parent.id,
31
+ content: parent.content,
32
+ icon: getStatusIcon(parent.status),
33
+ status: parent.status,
34
+ depth: 0,
35
+ ...(parent.note ? { note: parent.note } : {}),
36
+ });
37
+ for (const child of parent.children ?? []) {
38
+ items.push({
39
+ id: child.id,
40
+ content: child.content,
41
+ icon: getStatusIcon(child.status),
42
+ status: child.status,
43
+ depth: 1,
44
+ ...(child.note ? { note: child.note } : {}),
45
+ });
46
+ }
47
+ }
48
+ return { header, completed, total, items };
49
+ }
50
+ export function formatTodoProgress(todos) {
51
+ const summary = getTodoDisplaySummary(todos);
52
+ return summary ? summary.header : null;
53
+ }
@@ -0,0 +1,16 @@
1
+ import type { TodoChildern, TodoRootItem } from "../schemas/todo";
2
+ export declare const DEFAULT_STALE_MS: number;
3
+ export declare const CONTEXT_STALE_MS: number;
4
+ export declare const TODO_HINT: string;
5
+ export declare function isAnyTodoActive(todos: readonly TodoRootItem[]): boolean;
6
+ export interface ConsumeTodoHintOptions {
7
+ staleMs?: number;
8
+ now?: number;
9
+ }
10
+ export declare function consumeTodoHint(sessionID: string, options?: ConsumeTodoHintOptions): string | undefined;
11
+ type Leaf = TodoRootItem | TodoChildern;
12
+ export declare function flattenLeaves(todos: readonly TodoRootItem[]): Leaf[];
13
+ export declare function findNextTodo(todos: readonly TodoRootItem[]): Leaf | undefined;
14
+ export declare function findStuckParent(todos: readonly TodoRootItem[]): TodoRootItem | undefined;
15
+ export declare function buildTodoHint(sessionID: string, options?: ConsumeTodoHintOptions): string | undefined;
16
+ export {};