@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.2

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/src/rpc.ts ADDED
@@ -0,0 +1,290 @@
1
+ // todo-supervisor — RPC contract + server registration (PLAN.md §5.6, aspect 11).
2
+ //
3
+ // `TodoRpc` is the shared contract: the server registers it (handlers below)
4
+ // and the TUI imports it (via `opencode-todo-supervisor/rpc`) to build a
5
+ // client and to subscribe to `updated` / `nudged`.
6
+ //
7
+ // Schemas are portable JSON-schema descriptors (the plugin handshake cannot
8
+ // carry effect schemas), so handlers validate their input explicitly.
9
+ import type { Plugin } from "@opencode/plugin"
10
+ import { Rpc } from "@opencode/plugin/rpc"
11
+ import { effectiveFiles, MarkdownCache, resolveMarkdownPath, toggleMarkdownItem } from "./markdown"
12
+ import type { Options } from "./options"
13
+ import type { TodoStore } from "./storage"
14
+ import type { Supervisor } from "./supervisor"
15
+
16
+ // Kept for consumers/tests that import the resolver from the RPC module.
17
+ export { resolveMarkdownPath } from "./markdown"
18
+
19
+ const sessionInput = {
20
+ type: "object",
21
+ properties: { sessionID: { type: "string" } },
22
+ required: ["sessionID"],
23
+ additionalProperties: false,
24
+ } as const
25
+
26
+ const sessionPathInput = {
27
+ type: "object",
28
+ properties: { sessionID: { type: "string" }, path: { type: "string" } },
29
+ required: ["sessionID", "path"],
30
+ additionalProperties: false,
31
+ } as const
32
+
33
+ const objectOutput = { type: "object", additionalProperties: true } as const
34
+
35
+ export const TodoRpc = Rpc.define({
36
+ id: "todo",
37
+ methods: {
38
+ /** Current list + subscriptions + supervisor snapshot. */
39
+ list: { input: sessionInput, output: objectOutput },
40
+ /** Drop the stored list (subscriptions stay). */
41
+ clear: { input: sessionInput, output: objectOutput },
42
+ /** Send the nudge now (same dedupe/ownership rules as automatic nudges). */
43
+ nudge: { input: sessionInput, output: objectOutput },
44
+ /** Per-session supervision kill switch. */
45
+ setEnabled: {
46
+ input: {
47
+ type: "object",
48
+ properties: { sessionID: { type: "string" }, enabled: { type: "boolean" } },
49
+ required: ["sessionID", "enabled"],
50
+ additionalProperties: false,
51
+ } as const,
52
+ output: objectOutput,
53
+ },
54
+ /** Flip one session item between completed and pending (panel `x` action, aspect 12). */
55
+ toggle: {
56
+ input: {
57
+ type: "object",
58
+ properties: { sessionID: { type: "string" }, index: { type: "number" } },
59
+ required: ["sessionID", "index"],
60
+ additionalProperties: false,
61
+ } as const,
62
+ output: objectOutput,
63
+ },
64
+ /** Toggle a checklist item inside a subscribed markdown file (§5.8, panel `x`). */
65
+ toggleFile: {
66
+ input: {
67
+ type: "object",
68
+ properties: { sessionID: { type: "string" }, path: { type: "string" }, line: { type: "number" } },
69
+ required: ["sessionID", "path", "line"],
70
+ additionalProperties: false,
71
+ } as const,
72
+ output: objectOutput,
73
+ },
74
+ /** Subscribe to a markdown checklist file (§5.8); `.md`/`.markdown` only. */
75
+ subscribe: { input: sessionPathInput, output: objectOutput },
76
+ /** Shadow a subscription (config default or stored) — no longer counted. */
77
+ unsubscribe: { input: sessionPathInput, output: objectOutput },
78
+ },
79
+ events: {
80
+ updated: {
81
+ schema: {
82
+ type: "object",
83
+ properties: { sessionID: { type: "string" } },
84
+ required: ["sessionID"],
85
+ additionalProperties: false,
86
+ } as const,
87
+ },
88
+ nudged: {
89
+ schema: {
90
+ type: "object",
91
+ properties: { sessionID: { type: "string" }, reason: { type: "string" } },
92
+ required: ["sessionID", "reason"],
93
+ additionalProperties: false,
94
+ } as const,
95
+ },
96
+ },
97
+ })
98
+
99
+ export interface RpcDeps {
100
+ store: TodoStore
101
+ supervisor: Supervisor
102
+ options: Options
103
+ /** Per-file parse cache shared with the tools and the supervisor (§5.8). */
104
+ markdown: MarkdownCache
105
+ /** Location directory: relative subscription paths resolve against it (§5.8). */
106
+ directory: string
107
+ log: (...args: unknown[]) => void
108
+ }
109
+
110
+ export interface TodoRpcRegistration {
111
+ dispose(): Promise<void>
112
+ /** Fire-and-forget event emission (failures are logged, never thrown). */
113
+ emit(name: "updated" | "nudged", payload: Record<string, unknown>): void
114
+ }
115
+
116
+ function requireObject(value: unknown): Record<string, unknown> {
117
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
118
+ throw new Error("invalid input: expected an object")
119
+ }
120
+ return value as Record<string, unknown>
121
+ }
122
+
123
+ function requireString(value: unknown, field: string): string {
124
+ if (typeof value !== "string" || value.trim() === "") {
125
+ throw new Error(`invalid input: "${field}" must be a non-empty string`)
126
+ }
127
+ return value
128
+ }
129
+
130
+ function requireBoolean(value: unknown, field: string): boolean {
131
+ if (typeof value !== "boolean") throw new Error(`invalid input: "${field}" must be a boolean`)
132
+ return value
133
+ }
134
+
135
+ function requireNumber(value: unknown, field: string): number {
136
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
137
+ throw new Error(`invalid input: "${field}" must be a non-negative integer`)
138
+ }
139
+ return value
140
+ }
141
+
142
+ export type RpcFileItem = { content: string; status: "pending" | "completed" | "canceled"; line: number }
143
+ export type RpcFileSection = { path: string; exists: boolean; items: RpcFileItem[] }
144
+
145
+ /** Effective subscription set (§5.8): parsed fresh on every `list` call. */
146
+ async function collectFiles(store: TodoStore, deps: RpcDeps, sessionID: string): Promise<RpcFileSection[]> {
147
+ const subs = await store.loadSubs(sessionID)
148
+ return effectiveFiles(deps.options, deps.directory, subs).map((path) => {
149
+ const parsed = deps.markdown.parse(path)
150
+ return {
151
+ path,
152
+ exists: parsed.exists,
153
+ items: parsed.items.map((item) => ({
154
+ content: item.text,
155
+ status: item.state === "completed" ? "completed" : item.state === "canceled" ? "canceled" : "pending",
156
+ line: item.line,
157
+ })),
158
+ }
159
+ })
160
+ }
161
+
162
+ type RpcHandler = (raw: unknown, context?: unknown) => Promise<unknown>
163
+
164
+ /** Log and rethrow handler failures (the framework maps them to `rpc.internal`). */
165
+ function wrapHandlers<H extends Record<string, RpcHandler>>(handlers: H, log: (...args: unknown[]) => void): H {
166
+ const wrapped: Record<string, RpcHandler> = {}
167
+ for (const [name, handler] of Object.entries(handlers)) {
168
+ wrapped[name] = async (raw, context) => {
169
+ try {
170
+ return await handler(raw, context)
171
+ } catch (err) {
172
+ log(`rpc ${name} failed: ${String(err)}`)
173
+ throw err
174
+ }
175
+ }
176
+ }
177
+ return wrapped as H
178
+ }
179
+
180
+ export async function registerTodoRpc(ctx: Plugin.Context, deps: RpcDeps): Promise<TodoRpcRegistration> {
181
+ const { store, supervisor, log } = deps
182
+ let emitEvent: (name: "updated" | "nudged", payload: Record<string, unknown>) => Promise<void> = async () => {}
183
+
184
+ const registration = await ctx.rpc.register(TodoRpc, wrapHandlers({
185
+ list: async (raw) => {
186
+ const sessionID = requireString(requireObject(raw).sessionID, "sessionID")
187
+ await store.refreshTodos(sessionID)
188
+ return {
189
+ sessionID,
190
+ items: store.getTodos(sessionID)?.items ?? [],
191
+ files: await collectFiles(store, deps, sessionID),
192
+ supervisor: supervisor.snapshot(sessionID),
193
+ }
194
+ },
195
+ clear: async (raw) => {
196
+ const sessionID = requireString(requireObject(raw).sessionID, "sessionID")
197
+ await store.removeTodos(sessionID)
198
+ supervisor.notifyTodosChanged(sessionID)
199
+ await emitEvent("updated", { sessionID })
200
+ log(`rpc clear (${sessionID})`)
201
+ return { sessionID }
202
+ },
203
+ nudge: async (raw) => {
204
+ const sessionID = requireString(requireObject(raw).sessionID, "sessionID")
205
+ supervisor.forceNudge(sessionID)
206
+ log(`rpc nudge (${sessionID})`)
207
+ return { sessionID }
208
+ },
209
+ setEnabled: async (raw) => {
210
+ const input = requireObject(raw)
211
+ const sessionID = requireString(input.sessionID, "sessionID")
212
+ const enabled = requireBoolean(input.enabled, "enabled")
213
+ supervisor.setEnabled(sessionID, enabled)
214
+ log(`rpc setEnabled (${sessionID}) enabled=${enabled}`)
215
+ return { sessionID, enabled }
216
+ },
217
+ toggle: async (raw) => {
218
+ const input = requireObject(raw)
219
+ const sessionID = requireString(input.sessionID, "sessionID")
220
+ const index = requireNumber(input.index, "index")
221
+ const state = await store.refreshTodos(sessionID)
222
+ const items = state?.items ?? []
223
+ const item = items[index]
224
+ if (!item) throw new Error(`invalid input: "index" ${index} is out of range`)
225
+ const status: "pending" | "completed" = item.status === "completed" ? "pending" : "completed"
226
+ const next = items.map((entry, i) => (i === index ? { ...entry, status } : entry))
227
+ await store.setTodos(sessionID, next)
228
+ supervisor.notifyTodosChanged(sessionID)
229
+ await emitEvent("updated", { sessionID })
230
+ log(`rpc toggle (${sessionID}) index=${index} → ${status}`)
231
+ return { sessionID, index, status }
232
+ },
233
+ toggleFile: async (raw) => {
234
+ const input = requireObject(raw)
235
+ const sessionID = requireString(input.sessionID, "sessionID")
236
+ const line = requireNumber(input.line, "line")
237
+ if (line < 1) throw new Error(`invalid input: "line" must be a positive line number`)
238
+ const path = resolveMarkdownPath(deps.directory, requireString(input.path, "path"))
239
+ // Only files that are actually supervised may be edited (§5.8); arbitrary
240
+ // paths are rejected so the panel cannot write anywhere on disk.
241
+ const subscribed = effectiveFiles(deps.options, deps.directory, await store.loadSubs(sessionID))
242
+ if (!subscribed.includes(path)) throw new Error(`invalid input: "${path}" is not a subscribed markdown file`)
243
+ const state = toggleMarkdownItem(path, line)
244
+ deps.markdown.invalidate(path)
245
+ supervisor.notifyTodosChanged(sessionID)
246
+ await emitEvent("updated", { sessionID })
247
+ log(`rpc toggleFile (${sessionID}) ${path}:${line} → ${state}`)
248
+ return { sessionID, path, line, state }
249
+ },
250
+ subscribe: async (raw) => {
251
+ const input = requireObject(raw)
252
+ const sessionID = requireString(input.sessionID, "sessionID")
253
+ const path = resolveMarkdownPath(deps.directory, requireString(input.path, "path"))
254
+ const current = (await store.loadSubs(sessionID)) ?? { files: [], removed: [] }
255
+ const files = current.files.includes(path) ? current.files : [...current.files, path]
256
+ const removed = current.removed.filter((entry) => entry !== path)
257
+ await store.setSubs(sessionID, files, removed)
258
+ await emitEvent("updated", { sessionID })
259
+ log(`rpc subscribe (${sessionID}) ${path}`)
260
+ return { sessionID, path }
261
+ },
262
+ unsubscribe: async (raw) => {
263
+ const input = requireObject(raw)
264
+ const sessionID = requireString(input.sessionID, "sessionID")
265
+ const path = resolveMarkdownPath(deps.directory, requireString(input.path, "path"))
266
+ const current = (await store.loadSubs(sessionID)) ?? { files: [], removed: [] }
267
+ const files = current.files.filter((entry) => entry !== path)
268
+ const removed = current.removed.includes(path) ? current.removed : [...current.removed, path]
269
+ await store.setSubs(sessionID, files, removed)
270
+ await emitEvent("updated", { sessionID })
271
+ log(`rpc unsubscribe (${sessionID}) ${path}`)
272
+ return { sessionID, path }
273
+ },
274
+ }, log))
275
+
276
+ emitEvent = async (name, payload) => {
277
+ try {
278
+ await registration.events.emit(name, payload)
279
+ } catch (err) {
280
+ log(`rpc emit ${name} failed: ${String(err)}`)
281
+ }
282
+ }
283
+
284
+ return {
285
+ dispose: () => registration.dispose(),
286
+ emit: (name, payload) => {
287
+ void emitEvent(name, payload)
288
+ },
289
+ }
290
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ hashList,
4
+ isDone,
5
+ openItems,
6
+ TodoStore,
7
+ type StorageLike,
8
+ type TodoItem,
9
+ } from "./storage"
10
+
11
+ export class FakeStorage implements StorageLike {
12
+ readonly map = new Map<string, unknown>()
13
+
14
+ async get(key: string): Promise<unknown> {
15
+ return this.map.get(key)
16
+ }
17
+ async set(key: string, value: unknown): Promise<void> {
18
+ this.map.set(key, value)
19
+ }
20
+ async remove(key: string): Promise<void> {
21
+ this.map.delete(key)
22
+ }
23
+ async scan(options: { prefix: string; after?: string; limit?: number }) {
24
+ const limit = options.limit ?? 100
25
+ const keys = [...this.map.keys()].filter((k) => k.startsWith(options.prefix)).sort()
26
+ const start = options.after ? keys.indexOf(options.after) + 1 : 0
27
+ const slice = keys.slice(start, start + limit)
28
+ const last = slice[slice.length - 1]
29
+ return {
30
+ entries: slice.map((key) => ({ key, value: this.map.get(key) })),
31
+ next: start + slice.length < keys.length && last !== undefined ? last : undefined,
32
+ }
33
+ }
34
+ }
35
+
36
+ const items = (n: number): TodoItem[] =>
37
+ Array.from({ length: n }, (_, i) => ({ content: `task ${i}`, status: "pending" as const }))
38
+
39
+ describe("TodoStore", () => {
40
+ test("round-trips items identically in order", async () => {
41
+ const storage = new FakeStorage()
42
+ const store = new TodoStore(storage)
43
+ const written = items(3)
44
+ await store.setTodos("ses_1", written)
45
+ expect(store.getTodos("ses_1")?.items).toEqual(written)
46
+
47
+ const fresh = new TodoStore(storage)
48
+ const loaded = await fresh.loadTodos("ses_1")
49
+ expect(loaded?.items).toEqual(written)
50
+ })
51
+
52
+ test("scanAll paginates through more than one page", async () => {
53
+ const storage = new FakeStorage()
54
+ const store = new TodoStore(storage)
55
+ for (let i = 0; i < 5; i++) {
56
+ await storage.set(`todos/ses_${i}`, { version: 1, sessionID: `ses_${i}`, items: items(1), updatedAt: 0 })
57
+ }
58
+ await storage.set("subs/ses_9", { version: 1, sessionID: "ses_9", files: [], removed: [], updatedAt: 0 })
59
+
60
+ const fresh = new TodoStore(storage)
61
+ const all = await fresh.scanAll({ limit: 2 })
62
+ expect(all.map((s) => s.sessionID).sort()).toEqual(["ses_0", "ses_1", "ses_2", "ses_3", "ses_4"])
63
+ expect(fresh.getTodos("ses_4")?.items).toEqual(items(1))
64
+ expect(fresh.getSubs("ses_9")).toBeUndefined()
65
+ })
66
+
67
+ test("removeTodos deletes the key and the cache entry", async () => {
68
+ const storage = new FakeStorage()
69
+ const store = new TodoStore(storage)
70
+ await store.setTodos("ses_1", items(1))
71
+ await store.removeTodos("ses_1")
72
+ expect(storage.map.has("todos/ses_1")).toBe(false)
73
+ expect(store.getTodos("ses_1")).toBeUndefined()
74
+ })
75
+
76
+ test("garbage in storage is tolerated", async () => {
77
+ const storage = new FakeStorage()
78
+ await storage.set("todos/bad", { version: 2, items: "nope" })
79
+ await storage.set("todos/bad2", { version: 1, sessionID: "bad2", items: [{ content: "ok", status: "weird" }] })
80
+ const store = new TodoStore(storage)
81
+ const all = await store.scanAll()
82
+ expect(all.map((s) => s.sessionID)).toEqual(["bad2"])
83
+ expect(all[0]?.items).toEqual([])
84
+ })
85
+ })
86
+
87
+ describe("helpers", () => {
88
+ test("openItems / isDone", () => {
89
+ expect(openItems([{ content: "a", status: "completed" }, { content: "b", status: "pending" }])).toHaveLength(1)
90
+ expect(isDone([])).toBe(false)
91
+ expect(isDone([{ content: "a", status: "completed" }, { content: "b", status: "canceled" }])).toBe(true)
92
+ expect(isDone([{ content: "a", status: "pending" }])).toBe(false)
93
+ })
94
+
95
+ test("hashList is stable and order/content sensitive", () => {
96
+ const a: TodoItem[] = [{ content: "a", status: "pending", priority: "high" }]
97
+ const b: TodoItem[] = [{ content: "a", status: "pending", priority: "high" }]
98
+ expect(hashList(a)).toBe(hashList(b))
99
+ expect(hashList(a)).not.toBe(hashList([{ content: "a", status: "completed", priority: "high" }]))
100
+ })
101
+ })
package/src/storage.ts ADDED
@@ -0,0 +1,186 @@
1
+ // todo-supervisor — typed wrapper around `ctx.storage` (PLAN.md §5.3).
2
+ //
3
+ // Keys:
4
+ // todos/<sessionID> → { version: 1, sessionID, items, updatedAt }
5
+ // subs/<sessionID> → { version: 1, sessionID, files, removed, updatedAt }
6
+ //
7
+ // A full in-memory cache keeps the per-request context hook (§5.5)
8
+ // synchronous. Supervision counters are NOT stored — restart resets them.
9
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "canceled"
10
+ export type TodoPriority = "high" | "medium" | "low"
11
+
12
+ export type TodoItem = {
13
+ content: string
14
+ status: TodoStatus
15
+ priority?: TodoPriority
16
+ }
17
+
18
+ export type TodoState = {
19
+ version: 1
20
+ sessionID: string
21
+ items: TodoItem[]
22
+ updatedAt: number
23
+ }
24
+
25
+ export type SubsState = {
26
+ version: 1
27
+ sessionID: string
28
+ files: string[]
29
+ removed: string[]
30
+ updatedAt: number
31
+ }
32
+
33
+ /** Structural subset of `ctx.storage` (keeps this module testable). */
34
+ export interface StorageLike {
35
+ get(key: string): Promise<unknown>
36
+ set(key: string, value: unknown): Promise<void>
37
+ remove(key: string): Promise<void>
38
+ scan(options: { prefix: string; after?: string; limit?: number }): Promise<{
39
+ entries: readonly { key: string; value: unknown }[]
40
+ next?: string
41
+ }>
42
+ }
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return !!value && typeof value === "object" && !Array.isArray(value)
46
+ }
47
+
48
+ export function parseTodoState(value: unknown): TodoState | undefined {
49
+ if (!isRecord(value) || value.version !== 1) return undefined
50
+ if (typeof value.sessionID !== "string" || !Array.isArray(value.items)) return undefined
51
+ const items: TodoItem[] = []
52
+ for (const entry of value.items) {
53
+ if (!isRecord(entry) || typeof entry.content !== "string") continue
54
+ const status = entry.status
55
+ if (status !== "pending" && status !== "in_progress" && status !== "completed" && status !== "canceled") continue
56
+ const item: TodoItem = { content: entry.content, status }
57
+ if (entry.priority === "high" || entry.priority === "medium" || entry.priority === "low") {
58
+ item.priority = entry.priority
59
+ }
60
+ items.push(item)
61
+ }
62
+ return {
63
+ version: 1,
64
+ sessionID: value.sessionID,
65
+ items,
66
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
67
+ }
68
+ }
69
+
70
+ export function parseSubsState(value: unknown): SubsState | undefined {
71
+ if (!isRecord(value) || value.version !== 1) return undefined
72
+ if (typeof value.sessionID !== "string") return undefined
73
+ const files = Array.isArray(value.files) ? value.files.filter((f): f is string => typeof f === "string") : []
74
+ const removed = Array.isArray(value.removed) ? value.removed.filter((f): f is string => typeof f === "string") : []
75
+ return {
76
+ version: 1,
77
+ sessionID: value.sessionID,
78
+ files,
79
+ removed,
80
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
81
+ }
82
+ }
83
+
84
+ const todosKey = (sessionID: string) => `todos/${sessionID}`
85
+ const subsKey = (sessionID: string) => `subs/${sessionID}`
86
+
87
+ export class TodoStore {
88
+ private readonly todos = new Map<string, TodoState>()
89
+ private readonly subs = new Map<string, SubsState>()
90
+
91
+ constructor(private readonly storage: StorageLike) {}
92
+
93
+ // ---- todos ---------------------------------------------------------------
94
+
95
+ /** Cache-only read (kept in sync by every write, load and scan). */
96
+ getTodos(sessionID: string): TodoState | undefined {
97
+ return this.todos.get(sessionID)
98
+ }
99
+
100
+ /** Load one list from storage into the cache. */
101
+ async loadTodos(sessionID: string): Promise<TodoState | undefined> {
102
+ const parsed = parseTodoState(await this.storage.get(todosKey(sessionID)))
103
+ if (parsed) this.todos.set(sessionID, parsed)
104
+ return parsed
105
+ }
106
+
107
+ /** Re-read one list from shared storage; drops the cache entry when the row is gone. */
108
+ async refreshTodos(sessionID: string): Promise<TodoState | undefined> {
109
+ const parsed = parseTodoState(await this.storage.get(todosKey(sessionID)))
110
+ if (parsed) this.todos.set(sessionID, parsed)
111
+ else this.todos.delete(sessionID)
112
+ return parsed
113
+ }
114
+
115
+ async setTodos(sessionID: string, items: TodoItem[]): Promise<TodoState> {
116
+ const state: TodoState = { version: 1, sessionID, items, updatedAt: Date.now() }
117
+ await this.storage.set(todosKey(sessionID), state)
118
+ this.todos.set(sessionID, state)
119
+ return state
120
+ }
121
+
122
+ async removeTodos(sessionID: string): Promise<void> {
123
+ this.todos.delete(sessionID)
124
+ await this.storage.remove(todosKey(sessionID))
125
+ }
126
+
127
+ /** All stored lists, page by page; also refreshes the cache. */
128
+ async scanAll(options: { limit?: number } = {}): Promise<TodoState[]> {
129
+ const out: TodoState[] = []
130
+ let after: string | undefined
131
+ for (;;) {
132
+ const page = await this.storage.scan({ prefix: "todos/", after, limit: options.limit ?? 100 })
133
+ for (const entry of page.entries) {
134
+ const state = parseTodoState(entry.value)
135
+ if (!state) continue
136
+ out.push(state)
137
+ this.todos.set(state.sessionID, state)
138
+ }
139
+ if (!page.next) break
140
+ after = page.next
141
+ }
142
+ return out
143
+ }
144
+
145
+ // ---- markdown subscriptions ---------------------------------------------
146
+
147
+ getSubs(sessionID: string): SubsState | undefined {
148
+ return this.subs.get(sessionID)
149
+ }
150
+
151
+ async loadSubs(sessionID: string): Promise<SubsState | undefined> {
152
+ const parsed = parseSubsState(await this.storage.get(subsKey(sessionID)))
153
+ if (parsed) this.subs.set(sessionID, parsed)
154
+ return parsed
155
+ }
156
+
157
+ async setSubs(sessionID: string, files: string[], removed: string[]): Promise<SubsState> {
158
+ const state: SubsState = { version: 1, sessionID, files, removed, updatedAt: Date.now() }
159
+ await this.storage.set(subsKey(sessionID), state)
160
+ this.subs.set(sessionID, state)
161
+ return state
162
+ }
163
+
164
+ async removeSubs(sessionID: string): Promise<void> {
165
+ this.subs.delete(sessionID)
166
+ await this.storage.remove(subsKey(sessionID))
167
+ }
168
+ }
169
+
170
+ export function makeStore(storage: StorageLike): TodoStore {
171
+ return new TodoStore(storage)
172
+ }
173
+
174
+ // ---- predicates / helpers (§5.3) ------------------------------------------
175
+
176
+ export function openItems(items: readonly TodoItem[]): TodoItem[] {
177
+ return items.filter((item) => item.status !== "completed" && item.status !== "canceled")
178
+ }
179
+
180
+ export function isDone(items: readonly TodoItem[]): boolean {
181
+ return items.length > 0 && openItems(items).length === 0
182
+ }
183
+
184
+ export function hashList(items: readonly TodoItem[]): string {
185
+ return JSON.stringify(items.map((item) => [item.content, item.status, item.priority ?? ""]))
186
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { normalizeEvent, truncate } from "./supervisor"
3
+
4
+ describe("normalizeEvent", () => {
5
+ test("accepts the SSE envelope shape", () => {
6
+ const event = normalizeEvent({ id: "evt_1", type: "session.idle", data: { sessionID: "ses_1" } })
7
+ expect(event).toEqual({ type: "session.idle", data: { sessionID: "ses_1" } })
8
+ })
9
+
10
+ test("accepts a bare event object", () => {
11
+ const event = normalizeEvent({ type: "session.idle", sessionID: "ses_1" })
12
+ expect(event).toEqual({ type: "session.idle", data: { type: "session.idle", sessionID: "ses_1" } })
13
+ })
14
+
15
+ test("falls back to the source when data is not an object", () => {
16
+ const event = normalizeEvent({ type: "session.updated", data: "nope", sessionID: "ses_2" })
17
+ expect(event?.type).toBe("session.updated")
18
+ expect(event?.data.sessionID).toBe("ses_2")
19
+ })
20
+
21
+ test("rejects unusable input", () => {
22
+ expect(normalizeEvent(undefined)).toBeUndefined()
23
+ expect(normalizeEvent("session.idle")).toBeUndefined()
24
+ expect(normalizeEvent({})).toBeUndefined()
25
+ expect(normalizeEvent({ data: { sessionID: "ses_1" } })).toBeUndefined()
26
+ })
27
+ })
28
+
29
+ describe("truncate", () => {
30
+ test("passes short strings through", () => {
31
+ expect(truncate("hello", 10)).toBe("hello")
32
+ })
33
+
34
+ test("truncates long strings with a marker", () => {
35
+ expect(truncate("x".repeat(20), 5)).toBe("xxxxx…(15 more)")
36
+ })
37
+ })