opencode-session-todo 0.0.0-stage → 0.1.1

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.
package/index.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import type { Registration } from "@opencode/plugin/effect/registration"
3
+ import type { RpcHandlers } from "@opencode/plugin/effect/rpc"
4
+ import type { SessionContext } from "@opencode/plugin/effect/session"
5
+ import { Agent } from "@opencode/schema/agent"
6
+ import { Permission } from "@opencode/schema/permission"
7
+ import { Session } from "@opencode/schema/session"
8
+ import { Tool } from "@opencode/schema/tool"
9
+ import { Effect, Schema, type Scope } from "effect"
10
+ import { resolve } from "node:path"
11
+ import { TodoRpc } from "./rpc.ts"
12
+ import { diffTodos } from "./src/diff.ts"
13
+ import { TodoInstructions } from "./src/instructions.ts"
14
+ import { Snapshot, TodoError, Todos } from "./src/model.ts"
15
+ import { makeTodoStore, SessionDirectory, type TodoStore, type WriteResult } from "./src/store.ts"
16
+
17
+ const WriteInput = Schema.Struct({ previous: Todos, todos: Todos })
18
+ const ReadInput = Schema.Struct({})
19
+
20
+ const toolError = (error: TodoError): Tool.Error => new Tool.Error({ message: error.message })
21
+
22
+ export interface TodoPluginContext {
23
+ readonly location: Pick<Plugin.Context["location"], "directory">
24
+ readonly tool: Pick<Plugin.Context["tool"], "transform">
25
+ readonly rpc: Pick<Plugin.Context["rpc"], "register">
26
+ readonly session: Pick<Plugin.Context["session"], "get"> & {
27
+ readonly hook: (
28
+ name: "context",
29
+ callback: (event: SessionContext) => Effect.Effect<void>,
30
+ ) => Effect.Effect<Registration, never, Scope.Scope>
31
+ }
32
+ readonly agent: Pick<Plugin.Context["agent"], "get">
33
+ }
34
+
35
+ const matches = (value: string, pattern: string): boolean => {
36
+ const normalized: string = value.replaceAll("\\", "/")
37
+ let expression: string = pattern
38
+ .replaceAll("\\", "/")
39
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
40
+ .replace(/\*/g, ".*")
41
+ .replace(/\?/g, ".")
42
+ if (expression.endsWith(" .*")) expression = `${expression.slice(0, -3)}( .*)?`
43
+ return new RegExp(`^${expression}$`, process.platform === "win32" ? "si" : "s").test(normalized)
44
+ }
45
+
46
+ const allowed = (action: string, resource: string, rules: readonly Permission.Rule[]): boolean =>
47
+ rules.findLast(
48
+ (rule): boolean => matches(action, rule.action) && matches(resource, rule.resource),
49
+ )?.effect === "allow"
50
+
51
+ export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Scope.Scope> =>
52
+ Effect.gen(function*(): Effect.fn.Return<void, unknown, Scope.Scope> {
53
+ const store: TodoStore = yield* makeTodoStore
54
+ yield* ctx.session.hook(
55
+ "context",
56
+ (event): Effect.Effect<void> =>
57
+ Effect.gen(function*(): Effect.fn.Return<void> {
58
+ if (!Object.hasOwn(event.tools, "todowrite") && !Object.hasOwn(event.tools, "todoread")) {
59
+ return
60
+ }
61
+ const root: boolean = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(
62
+ Effect.match({
63
+ onSuccess: (session): boolean => session.parentID === undefined,
64
+ onFailure: (): boolean => false,
65
+ }),
66
+ )
67
+ if (!root) {
68
+ delete event.tools.todowrite
69
+ delete event.tools.todoread
70
+ return
71
+ }
72
+ if (!Object.hasOwn(event.tools, "todowrite") || !Object.hasOwn(event.tools, "todoread")) {
73
+ return
74
+ }
75
+ if (
76
+ event.system.some(
77
+ (part): boolean => part.type === "text" && part.text === TodoInstructions,
78
+ )
79
+ ) {
80
+ return
81
+ }
82
+ event.system.push({ type: "text", text: TodoInstructions })
83
+ }),
84
+ )
85
+ const getSession = Effect.fnUntraced(function*(
86
+ sessionID: Session.ID,
87
+ ): Effect.fn.Return<Session.Info, TodoError> {
88
+ const session: Session.Info = yield* ctx.session
89
+ .get({ sessionID })
90
+ .pipe(
91
+ Effect.mapError(
92
+ (error): TodoError =>
93
+ new TodoError({ message: `Cannot find session: ${String(error)}` }),
94
+ ),
95
+ )
96
+ if (resolve(session.location.directory) !== resolve(ctx.location.directory)) {
97
+ return yield* new TodoError({
98
+ message: "The todo session belongs to another project location",
99
+ })
100
+ }
101
+ return session
102
+ })
103
+
104
+ const directoryFor = (session: Session.Info): Effect.Effect<SessionDirectory, TodoError> =>
105
+ Schema.decodeUnknownEffect(SessionDirectory)({
106
+ directory: session.location.directory,
107
+ name: session.id,
108
+ }).pipe(Effect.mapError((error): TodoError => new TodoError({ message: error.message })))
109
+
110
+ const read = Effect.fnUntraced(function*(
111
+ sessionID: Session.ID,
112
+ ): Effect.fn.Return<Snapshot, TodoError> {
113
+ return yield* store.read(yield* directoryFor(yield* getSession(sessionID)))
114
+ })
115
+
116
+ const rpcHandlers: RpcHandlers<typeof TodoRpc> = {
117
+ read: (input, context): ReturnType<RpcHandlers<typeof TodoRpc>["read"]> =>
118
+ read(input.sessionID).pipe(
119
+ Effect.mapError(
120
+ (error): ReturnType<typeof context.error<"storage">> =>
121
+ context.error("storage", error.message, { message: error.message }),
122
+ ),
123
+ ),
124
+ }
125
+ const rpc = yield* ctx.rpc.register(TodoRpc, rpcHandlers)
126
+
127
+ const executeWrite: Tool.Info<typeof WriteInput, typeof Snapshot>["execute"] = Effect.fn(
128
+ "todo.write",
129
+ )(function*(input, context): Effect.fn.Return<Tool.Result<typeof Snapshot>, Tool.Error> {
130
+ const session: Session.Info = yield* getSession(context.sessionID).pipe(
131
+ Effect.mapError(toolError),
132
+ )
133
+ const agent: Agent.Info = (yield* ctx.agent
134
+ .get({ agentID: context.agent })
135
+ .pipe(
136
+ Effect.mapError(
137
+ (error): Tool.Error =>
138
+ new Tool.Error({ message: `Cannot resolve todo permissions: ${String(error)}` }),
139
+ ),
140
+ )).data
141
+ const rules: readonly Permission.Rule[] = [
142
+ ...agent.permissions,
143
+ ...(session.permissions ?? []),
144
+ ]
145
+ const resource: string = `.agents/sessions/${session.id}/todo.txt`
146
+ if (!allowed("todowrite", resource, rules)) {
147
+ return yield* new Tool.Error({
148
+ message:
149
+ "todowrite requires an allow permission. OpenCode V2 does not expose custom-tool approval prompts; deny and ask are rejected.",
150
+ })
151
+ }
152
+ const target: SessionDirectory = yield* directoryFor(session).pipe(
153
+ Effect.mapError(toolError),
154
+ )
155
+ const saved: WriteResult = yield* store.write(target, input.todos, input.previous).pipe(
156
+ Effect.mapError(toolError),
157
+ )
158
+ const diff: string = diffTodos(saved.previous, saved.todos)
159
+ yield* rpc.events
160
+ .emit("updated", { sessionID: context.sessionID, diff })
161
+ .pipe(
162
+ Effect.catch(
163
+ (error): Effect.Effect<void> =>
164
+ Effect.logWarning("Todo saved, but notification failed", error),
165
+ ),
166
+ )
167
+ return {
168
+ output: { path: saved.path, todos: saved.todos },
169
+ content: diff === "" ? "Todo list unchanged." : `Todo list updated:\n${diff}`,
170
+ metadata: { todos: saved.todos, path: saved.path },
171
+ }
172
+ })
173
+
174
+ const executeRead: Tool.Info<typeof ReadInput, typeof Snapshot>["execute"] = (
175
+ _input,
176
+ context,
177
+ ): Effect.Effect<Tool.Result<typeof Snapshot>, Tool.Error> => {
178
+ return read(context.sessionID).pipe(
179
+ Effect.map(
180
+ (snapshot): Tool.Result<typeof Snapshot> => ({
181
+ output: snapshot,
182
+ content: JSON.stringify(snapshot),
183
+ }),
184
+ ),
185
+ Effect.mapError(toolError),
186
+ )
187
+ }
188
+
189
+ yield* ctx.tool.transform((editor): void => {
190
+ editor.add({
191
+ name: "todowrite",
192
+ description:
193
+ `Replace this session's ordered todo list when it still equals the \`previous\` list you send; take \`previous\` from the latest todoread or todowrite result in this conversation. A mismatch fails the write: use todoread, then retry. Status: pending, in_progress, completed, cancelled; at most one in_progress. Priority: high, medium, low. Text is one trimmed line; status:/pri: are reserved. An empty todos array clears the list. Persists as project-local todo.txt and updates the TUI.\n\n${TodoInstructions}`,
194
+ input: WriteInput,
195
+ output: Snapshot,
196
+ options: { codemode: false, permission: "todowrite" },
197
+ execute: executeWrite,
198
+ })
199
+ editor.add({
200
+ name: "todoread",
201
+ description:
202
+ `Read this session's todo.txt list and storage path. Use it to obtain the \`previous\` list todowrite expects, or to retry after a failed write; missing files mean an empty list.\n\n${TodoInstructions}`,
203
+ input: ReadInput,
204
+ output: Snapshot,
205
+ options: { codemode: false, permission: "todowrite" },
206
+ execute: executeRead,
207
+ })
208
+ })
209
+ }).pipe(Effect.orDie)
210
+
211
+ export default Plugin.define({ id: "session-todo", effect: register })
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "opencode-session-todo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Session-local todo.txt tools and TUI for OpenCode V2",
5
+ "homepage": "https://github.com/alexandru/opencode-todo#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/alexandru/opencode-todo/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/alexandru/opencode-todo.git"
12
+ },
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./index.ts",
17
+ "./tui": "./dist/tui.js",
18
+ "./rpc": "./rpc.ts"
19
+ },
20
+ "files": [
21
+ "index.ts",
22
+ "tui.tsx",
23
+ "dist",
24
+ "rpc.ts",
25
+ "src",
26
+ "USAGE.md",
27
+ "LICENSE",
28
+ "README.md",
29
+ "docs/development.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "bun run build.ts",
33
+ "check": "tsc --noEmit",
34
+ "test": "bun test",
35
+ "format": "dprint fmt",
36
+ "format:check": "dprint check",
37
+ "prepack": "bun run build",
38
+ "prepublishOnly": "bun run check && bun run test && bun run format:check"
39
+ },
40
+ "dependencies": {
41
+ "@opencode/plugin": "2.0.27",
42
+ "@opencode/schema": "2.0.27",
43
+ "effect": "4.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "@opencode/theme": "2.0.27",
47
+ "@opentui/core": "0.5.17",
48
+ "@opentui/solid": "0.5.17",
49
+ "@types/bun": "1.3.11",
50
+ "dprint": "0.63.0",
51
+ "solid-js": "1.9.12",
52
+ "typescript": "5.9.3"
53
+ },
54
+ "peerDependencies": {
55
+ "@opentui/core": ">=0.5.17",
56
+ "@opentui/solid": ">=0.5.17",
57
+ "solid-js": ">=1.9.0"
58
+ },
59
+ "engines": {
60
+ "bun": ">=1.4.2"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
package/rpc.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { Rpc } from "@opencode/schema/rpc"
2
+ import { Session } from "@opencode/schema/session"
3
+ import { Schema } from "effect"
4
+ import { Snapshot } from "./src/model.ts"
5
+
6
+ export const TodoRpc = Rpc.define({
7
+ id: "session-todo",
8
+ methods: {
9
+ read: {
10
+ input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Session.ID })),
11
+ output: Schema.toStandardSchemaV1(Snapshot),
12
+ errors: { storage: Schema.toStandardSchemaV1(Schema.Struct({ message: Schema.String })) },
13
+ },
14
+ },
15
+ events: {
16
+ updated: {
17
+ schema: Schema.toStandardSchemaV1(
18
+ Schema.Struct({ sessionID: Session.ID, diff: Schema.String }),
19
+ ),
20
+ },
21
+ },
22
+ })
package/src/diff.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { Todo, Todos } from "./model.ts"
2
+
3
+ const lineFor = (todo: Todo): string => `[${todo.status}|${todo.priority}] ${todo.content}`
4
+
5
+ const MAX_CELLS: number = 1 << 20
6
+
7
+ const replaced = (previous: readonly string[], next: readonly string[]): readonly string[] => [
8
+ ...previous.map((line: string): string => `- ${line}`),
9
+ ...next.map((line: string): string => `+ ${line}`),
10
+ ]
11
+
12
+ const advance = (cursor: Iterator<string>): string | undefined => {
13
+ const step: IteratorResult<string> = cursor.next()
14
+ return step.done ? undefined : step.value
15
+ }
16
+
17
+ const diffLines = (previous: readonly string[], next: readonly string[]): readonly string[] => {
18
+ if (
19
+ previous.length === 0 || next.length === 0
20
+ || previous.length * next.length > MAX_CELLS
21
+ ) return replaced(previous, next)
22
+ const width: number = next.length + 1
23
+ const lengths: Int32Array = new Int32Array((previous.length + 1) * width)
24
+ const cell = (index: number): number => lengths[index] ?? 0
25
+ previous.reduceRight<void>((_, prevLine: string, i: number): void => {
26
+ next.reduceRight<void>((_, nextLine: string, j: number): void => {
27
+ lengths[i * width + j] = prevLine === nextLine
28
+ ? cell((i + 1) * width + j + 1) + 1
29
+ : Math.max(cell((i + 1) * width + j), cell(i * width + j + 1))
30
+ }, undefined)
31
+ }, undefined)
32
+ const lines: string[] = []
33
+ const previousCursor: Iterator<string> = previous[Symbol.iterator]()
34
+ const nextCursor: Iterator<string> = next[Symbol.iterator]()
35
+ let i: number = 0
36
+ let j: number = 0
37
+ let previousLine: string | undefined = advance(previousCursor)
38
+ let nextLine: string | undefined = advance(nextCursor)
39
+ while (previousLine !== undefined || nextLine !== undefined) {
40
+ if (previousLine === nextLine) {
41
+ i += 1
42
+ j += 1
43
+ previousLine = advance(previousCursor)
44
+ nextLine = advance(nextCursor)
45
+ continue
46
+ }
47
+ if (
48
+ previousLine !== undefined
49
+ && (nextLine === undefined || cell((i + 1) * width + j) >= cell(i * width + j + 1))
50
+ ) {
51
+ lines.push(`- ${previousLine}`)
52
+ i += 1
53
+ previousLine = advance(previousCursor)
54
+ } else if (nextLine !== undefined) {
55
+ lines.push(`+ ${nextLine}`)
56
+ j += 1
57
+ nextLine = advance(nextCursor)
58
+ }
59
+ }
60
+ return lines
61
+ }
62
+
63
+ export const diffTodos = (previous: Todos, next: Todos): string =>
64
+ diffLines(previous.map(lineFor), next.map(lineFor)).join("\n")
@@ -0,0 +1,10 @@
1
+ export const TodoInstructions: string = `TODO lifecycle
2
+
3
+ - You MUST keep this session's TODO list synchronized with the requested work and its actual progress.
4
+ - You MUST pass the list you expect to be stored as \`previous\` to every todowrite; when the write reports the stored list changed, you MUST use todoread and retry with the refreshed list. You MUST preserve existing tasks, order, statuses, and priorities unless actual progress or the user's instructions require a change.
5
+ - You MUST mark a task in_progress before starting it. You MUST keep at most one task in_progress.
6
+ - You MUST mark a task completed immediately after finishing its work and required verification.
7
+ - You MUST NOT mark blocked, failed, skipped, unfinished, or unverified work completed. Unstarted or deferred work MUST remain pending; cancelled work MUST be represented as cancelled.
8
+ - You MUST update the list after each meaningful status transition. You MUST NOT batch status updates until the end of the conversation or leave finished work in_progress.
9
+ - Before your final response, you MUST reconcile the list with what was actually done and accurately retain unfinished work.
10
+ - You SHOULD keep task descriptions concise and actionable.`
package/src/model.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { Schema } from "effect"
2
+
3
+ export const Content = Schema.String.check(
4
+ Schema.isMinLength(1),
5
+ Schema.isTrimmed(),
6
+ Schema.isPattern(/^[^\r\n\x00]+$/),
7
+ Schema.makeFilter(
8
+ (value: string): boolean =>
9
+ !/^(?:\([A-Z]\) |\d{4}-\d{2}-\d{2}(?: |$))/.test(value)
10
+ && !/(?:^|\s)(?:status|pri):/.test(value),
11
+ {
12
+ message:
13
+ "Task text must not start with a priority/date or contain reserved status:/pri: tokens",
14
+ },
15
+ ),
16
+ ).pipe(Schema.brand("TodoContent"))
17
+
18
+ export const Status = Schema.Literals(["pending", "in_progress", "completed", "cancelled"])
19
+ export const Priority = Schema.Literals(["high", "medium", "low"])
20
+ export const Todo = Schema.Struct({ content: Content, status: Status, priority: Priority })
21
+ export type Todo = typeof Todo.Type
22
+
23
+ export const Todos = Schema.Array(Todo)
24
+ .check(
25
+ Schema.makeFilter(
26
+ (todos: readonly Todo[]): boolean => {
27
+ let active: boolean = false
28
+ for (const todo of todos) {
29
+ if (todo.status !== "in_progress") continue
30
+ if (active) return false
31
+ active = true
32
+ }
33
+ return true
34
+ },
35
+ { message: "At most one todo may be in_progress" },
36
+ ),
37
+ )
38
+ .pipe(Schema.brand("TodoList"))
39
+ export type Todos = typeof Todos.Type
40
+
41
+ export const SessionName = Schema.String.check(
42
+ Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
43
+ ).pipe(Schema.brand("TodoSessionName"))
44
+ export type SessionName = typeof SessionName.Type
45
+
46
+ export const Snapshot = Schema.Struct({ path: Schema.String, todos: Todos })
47
+ export type Snapshot = typeof Snapshot.Type
48
+
49
+ export class TodoError extends Schema.TaggedError<TodoError>()("TodoError", {
50
+ message: Schema.String,
51
+ }) {}
package/src/sidebar.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { Effect, Schema } from "effect"
2
+ import type { Todo, Todos } from "./model.ts"
3
+
4
+ export const SidebarLimit = Schema.Int.check(Schema.isGreaterThan(0)).pipe(
5
+ Schema.brand("TodoSidebarLimit"),
6
+ )
7
+ export type SidebarLimit = typeof SidebarLimit.Type
8
+
9
+ export const SidebarOptions = Schema.Struct({
10
+ sidebarLimit: SidebarLimit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(5))),
11
+ })
12
+
13
+ export type TodoProjection =
14
+ | { readonly kind: "all"; readonly items: readonly Todo[]; readonly remaining: number }
15
+ | {
16
+ readonly kind: "limited"
17
+ readonly items: readonly Todo[]
18
+ readonly remaining: number
19
+ readonly total: number
20
+ }
21
+
22
+ const incomplete = (todo: Todo): boolean =>
23
+ todo.status === "pending" || todo.status === "in_progress"
24
+
25
+ export const projectTodos = (todos: Todos, mode: "full" | SidebarLimit): TodoProjection => {
26
+ if (mode === "full" || todos.length <= mode) {
27
+ return {
28
+ kind: "all",
29
+ items: todos,
30
+ remaining: todos.reduce((count, todo): number => count + Number(incomplete(todo)), 0),
31
+ }
32
+ }
33
+ const items: Todo[] = []
34
+ let remaining: number = 0
35
+ for (const todo of todos) {
36
+ if (!incomplete(todo)) continue
37
+ remaining += 1
38
+ if (todo.status === "in_progress") {
39
+ if (items.length === mode) items.pop()
40
+ items.unshift(todo)
41
+ } else if (items.length < mode) items.push(todo)
42
+ }
43
+ return { kind: "limited", items, remaining, total: todos.length }
44
+ }
package/src/store.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { DateTime, Effect, Schema, Semaphore } from "effect"
2
+ import { randomUUID } from "node:crypto"
3
+ import type { Stats } from "node:fs"
4
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
5
+ import { isAbsolute, join } from "node:path"
6
+ import { SessionName, Snapshot, Todo, TodoError, Todos } from "./model.ts"
7
+ import { type Document, parse, render } from "./todotxt.ts"
8
+
9
+ export const SessionDirectory = Schema.Struct({
10
+ directory: Schema.String.check(
11
+ Schema.makeFilter((directory: string): boolean => isAbsolute(directory), {
12
+ message: "The todo project directory must be absolute",
13
+ }),
14
+ ).pipe(Schema.brand("TodoProjectDirectory")),
15
+ name: SessionName,
16
+ })
17
+ export type SessionDirectory = typeof SessionDirectory.Type
18
+
19
+ export interface WriteResult extends Snapshot {
20
+ readonly previous: Todos
21
+ }
22
+
23
+ export interface TodoStore {
24
+ readonly read: (session: SessionDirectory) => Effect.Effect<Snapshot, TodoError>
25
+ readonly write: (
26
+ session: SessionDirectory,
27
+ todos: Todos,
28
+ expected?: Todos,
29
+ ) => Effect.Effect<WriteResult, TodoError>
30
+ }
31
+
32
+ const sameTodos = (left: readonly Todo[], right: readonly Todo[]): boolean => {
33
+ if (left.length !== right.length) return false
34
+ const cursor: Iterator<Todo> = right[Symbol.iterator]()
35
+ for (const todo of left) {
36
+ const step: IteratorResult<Todo> = cursor.next()
37
+ if (
38
+ step.done
39
+ || step.value.content !== todo.content
40
+ || step.value.status !== todo.status
41
+ || step.value.priority !== todo.priority
42
+ ) {
43
+ return false
44
+ }
45
+ }
46
+ return true
47
+ }
48
+
49
+ const io = <A>(message: string, run: () => Promise<A>): Effect.Effect<A, TodoError> =>
50
+ Effect.tryPromise({
51
+ try: run,
52
+ catch: (error): TodoError => new TodoError({ message: `${message}: ${String(error)}` }),
53
+ })
54
+
55
+ const missing = (error: unknown): boolean =>
56
+ Schema.is(Schema.Struct({ code: Schema.Literal("ENOENT") }))(error)
57
+
58
+ const pathFor = (session: SessionDirectory): string =>
59
+ join(session.directory, ".agents", "sessions", session.name, "todo.txt")
60
+
61
+ const requireDirectory = async (directory: string): Promise<void> => {
62
+ const info: Stats = await lstat(directory)
63
+ if (!info.isDirectory()) {
64
+ throw new Error(`${directory} must be a real directory`)
65
+ }
66
+ }
67
+
68
+ const load = Effect.fnUntraced(function*(path: string): Effect.fn.Return<Document, TodoError> {
69
+ const text: string = yield* io(`Cannot read ${path}`, async (): Promise<string> => {
70
+ try {
71
+ const info: Stats = await lstat(path)
72
+ if (!info.isFile()) {
73
+ throw new Error("todo.txt must be a regular file")
74
+ }
75
+ return await readFile(path, "utf8")
76
+ } catch (error) {
77
+ if (missing(error)) return ""
78
+ throw error
79
+ }
80
+ })
81
+ return yield* parse(text)
82
+ })
83
+
84
+ const checkDirectory = Effect.fnUntraced(function*(
85
+ session: SessionDirectory,
86
+ ): Effect.fn.Return<void, TodoError> {
87
+ yield* io("Cannot access session directory", async (): Promise<void> => {
88
+ let directory: string = await realpath(session.directory)
89
+ for (const segment of [".agents", "sessions", session.name]) {
90
+ directory = join(directory, segment)
91
+ try {
92
+ await requireDirectory(directory)
93
+ } catch (error) {
94
+ if (missing(error)) return
95
+ throw error
96
+ }
97
+ }
98
+ })
99
+ })
100
+
101
+ const ensureDirectory = Effect.fnUntraced(function*(
102
+ session: SessionDirectory,
103
+ ): Effect.fn.Return<void, TodoError> {
104
+ yield* io("Cannot prepare session directory", async (): Promise<void> => {
105
+ let directory: string = await realpath(session.directory)
106
+ for (const segment of [".agents", "sessions", session.name]) {
107
+ directory = join(directory, segment)
108
+ await mkdir(directory, { recursive: true, mode: 0o700 })
109
+ await requireDirectory(directory)
110
+ }
111
+ })
112
+ })
113
+
114
+ export const makeTodoStore: Effect.Effect<TodoStore> = Effect.gen(
115
+ function*(): Effect.fn.Return<TodoStore> {
116
+ const lock: Semaphore.Semaphore = yield* Semaphore.make(1)
117
+ const read: TodoStore["read"] = Effect.fnUntraced(function*(
118
+ session: SessionDirectory,
119
+ ): Effect.fn.Return<Snapshot, TodoError> {
120
+ const path: string = pathFor(session)
121
+ yield* checkDirectory(session)
122
+ const document: Document = yield* load(path)
123
+ return { path, todos: document.todos }
124
+ })
125
+ const write: TodoStore["write"] = (
126
+ session,
127
+ todos,
128
+ expected,
129
+ ): Effect.Effect<WriteResult, TodoError> =>
130
+ lock.withPermit(
131
+ Effect.uninterruptible(
132
+ Effect.gen(function*(): Effect.fn.Return<WriteResult, TodoError> {
133
+ const path: string = pathFor(session)
134
+ yield* ensureDirectory(session)
135
+ const previous: Document = yield* load(path)
136
+ if (expected !== undefined && !sameTodos(expected, previous.todos)) {
137
+ return yield* new TodoError({
138
+ message:
139
+ "The stored todo list does not match the `previous` list you sent. Use todoread, then retry todowrite with the refreshed `previous` list.",
140
+ })
141
+ }
142
+ const temporary: string = `${path}.${randomUUID()}.tmp`
143
+ const today: string = DateTime.formatIsoDateUtc(yield* DateTime.now)
144
+ yield* io(`Cannot write ${path}`, async (): Promise<void> => {
145
+ try {
146
+ await writeFile(temporary, render(todos, previous.records, today), {
147
+ encoding: "utf8",
148
+ mode: 0o600,
149
+ flag: "wx",
150
+ })
151
+ await rename(temporary, path)
152
+ } finally {
153
+ await rm(temporary, { force: true })
154
+ }
155
+ })
156
+ return { path, todos, previous: previous.todos }
157
+ }),
158
+ ),
159
+ )
160
+ return { read, write }
161
+ },
162
+ )