@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.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/src/panel.ts ADDED
@@ -0,0 +1,289 @@
1
+ // todo-supervisor — pure presentation helpers for the TUI todo panel (§6, aspect 12).
2
+ //
3
+ // Everything here is framework-free: layout math, row building, cursor movement
4
+ // and truncation are unit-tested without an OpenTUI renderer. src/tui.tsx only
5
+ // maps these rows to <text>/<box> elements.
6
+
7
+ export type PanelItem = { content: string; status: string; priority?: string }
8
+ export type PanelFileItem = { content: string; status: string; line?: number }
9
+ export type PanelFile = { path: string; exists: boolean; items: PanelFileItem[] }
10
+
11
+ /** RPC `list` → `supervisor` payload (see Supervisor.snapshot). */
12
+ export type PanelSupervisor = {
13
+ registered?: boolean
14
+ status?: string
15
+ paused?: boolean
16
+ pauseReason?: string | null
17
+ blocked?: number
18
+ runningChildren?: number
19
+ runningShells?: number
20
+ runningTools?: number
21
+ spans?: string[]
22
+ quietMs?: number
23
+ terminal?: boolean
24
+ nudges?: number
25
+ noProgress?: number
26
+ }
27
+
28
+ export type PanelSnapshot = {
29
+ items: PanelItem[]
30
+ files: PanelFile[]
31
+ supervisor?: PanelSupervisor
32
+ }
33
+
34
+ export type Row =
35
+ | { kind: "section"; key: string; label: string; detail?: string; muted?: boolean }
36
+ | {
37
+ kind: "file"
38
+ key: string
39
+ path: string
40
+ label: string
41
+ open: number
42
+ total: number
43
+ exists: boolean
44
+ collapsed: boolean
45
+ }
46
+ | {
47
+ kind: "item"
48
+ key: string
49
+ source: "session" | "file"
50
+ content: string
51
+ status: string
52
+ priority?: string
53
+ path?: string
54
+ line?: number
55
+ index?: number
56
+ }
57
+
58
+ export type ItemRow = Extract<Row, { kind: "item" }>
59
+ export type FileRow = Extract<Row, { kind: "file" }>
60
+
61
+ export function isSelectable(row: Row): row is ItemRow | FileRow {
62
+ return row.kind === "item" || row.kind === "file"
63
+ }
64
+
65
+ /** `[ ]` / `[~]` (in progress or canceled) / `[x]` — matches todo_read. */
66
+ export function statusMark(status: string): string {
67
+ if (status === "completed") return "[x]"
68
+ if (status === "in_progress" || status === "canceled") return "[~]"
69
+ return "[ ]"
70
+ }
71
+
72
+ export function countOpen(items: readonly { status: string }[]): number {
73
+ return items.filter((item) => item.status !== "completed").length
74
+ }
75
+
76
+ /** `████░░░░` filled by completion ratio; `░` when there is nothing to do. */
77
+ export function progressBar(done: number, total: number, width: number): string {
78
+ const cells = Math.max(0, Math.floor(width))
79
+ if (cells === 0) return ""
80
+ if (total <= 0) return "░".repeat(cells)
81
+ const filled = Math.max(0, Math.min(cells, Math.round((done / total) * cells)))
82
+ return "█".repeat(filled) + "░".repeat(cells - filled)
83
+ }
84
+
85
+ /** Single-line, whitespace-collapsed, ellipsised to `width` cells. */
86
+ export function truncate(text: string, width: number): string {
87
+ const clean = text.replace(/\s+/g, " ").trim()
88
+ if (width <= 0) return ""
89
+ if (clean.length <= width) return clean
90
+ if (width === 1) return "…"
91
+ return `${clean.slice(0, width - 1)}…`
92
+ }
93
+
94
+ /** `3s` / `1m20s` — compact quiet-time label. */
95
+ export function quietLabel(ms: number | undefined): string {
96
+ if (ms === undefined) return "—"
97
+ const seconds = Math.max(0, Math.round(ms / 1000))
98
+ if (seconds < 60) return `${seconds}s`
99
+ return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`
100
+ }
101
+
102
+ /** One-line supervision summary for the panel header. */
103
+ export function supervisorChip(supervisor: PanelSupervisor | undefined): string {
104
+ if (!supervisor || supervisor.registered === false) return "not supervised"
105
+ const nudges = supervisor.nudges ?? 0
106
+ if (supervisor.paused) return `paused (${supervisor.pauseReason ?? "paused"}) · nudges ${nudges}`
107
+ const work: string[] = []
108
+ const plural = (count: number, one: string) => `${count} ${one}${count === 1 ? "" : "s"}`
109
+ if (supervisor.runningTools) work.push(plural(supervisor.runningTools, "tool"))
110
+ if (supervisor.runningChildren) work.push(plural(supervisor.runningChildren, "subagent"))
111
+ if (supervisor.runningShells) work.push(plural(supervisor.runningShells, "shell"))
112
+ if (work.length > 0) return `working: ${work.join(" · ")} · quiet ${quietLabel(supervisor.quietMs)}`
113
+ if (supervisor.terminal === false) return `waiting for turn end · quiet ${quietLabel(supervisor.quietMs)}`
114
+ return `idle · quiet ${quietLabel(supervisor.quietMs)} · nudges ${nudges}`
115
+ }
116
+
117
+ export type BuildOptions = {
118
+ /** Case-insensitive substring filter over item content. */
119
+ filter?: string
120
+ /** Collapsed markdown file paths (their items are hidden). */
121
+ collapsed?: ReadonlySet<string>
122
+ /** Path display, e.g. `context.ui.format.path` (defaults to identity). */
123
+ formatPath?: (path: string) => string
124
+ }
125
+
126
+ function matches(text: string, filter: string): boolean {
127
+ return filter === "" || text.toLowerCase().includes(filter)
128
+ }
129
+
130
+ /**
131
+ * Flatten a snapshot into panel rows: `SESSION` section, then one section per
132
+ * subscribed file (collapsible), each item selectable. Filters hide items and
133
+ * whole sections that no longer match; the cursor is kept by row key elsewhere.
134
+ */
135
+ export function buildRows(snapshot: PanelSnapshot, options: BuildOptions = {}): Row[] {
136
+ const filter = (options.filter ?? "").trim().toLowerCase()
137
+ const collapsed = options.collapsed ?? new Set<string>()
138
+ const formatPath = options.formatPath ?? ((path: string) => path)
139
+ const rows: Row[] = []
140
+
141
+ const sessionVisible = snapshot.items.filter((item) => matches(item.content, filter))
142
+ if (snapshot.items.length > 0 && sessionVisible.length > 0) {
143
+ rows.push({
144
+ kind: "section",
145
+ key: "section:session",
146
+ label: "SESSION",
147
+ detail: `${countOpen(snapshot.items)} open of ${snapshot.items.length}`,
148
+ })
149
+ for (const item of sessionVisible) {
150
+ const index = snapshot.items.indexOf(item)
151
+ rows.push({
152
+ kind: "item",
153
+ key: `session:${index}`,
154
+ source: "session",
155
+ index,
156
+ content: item.content,
157
+ status: item.status,
158
+ priority: item.priority,
159
+ })
160
+ }
161
+ }
162
+
163
+ for (const file of snapshot.files) {
164
+ const visible = file.items.filter((item) => matches(item.content, filter))
165
+ if (filter !== "" && visible.length === 0) continue
166
+ const isCollapsed = collapsed.has(file.path)
167
+ rows.push({
168
+ kind: "file",
169
+ key: `file:${file.path}`,
170
+ path: file.path,
171
+ label: formatPath(file.path),
172
+ open: countOpen(file.items),
173
+ total: file.items.length,
174
+ exists: file.exists,
175
+ collapsed: isCollapsed,
176
+ })
177
+ if (isCollapsed) continue
178
+ for (const item of visible) {
179
+ rows.push({
180
+ kind: "item",
181
+ key: `file:${file.path}:${item.line ?? item.content}`,
182
+ source: "file",
183
+ path: file.path,
184
+ line: item.line,
185
+ content: item.content,
186
+ status: item.status,
187
+ })
188
+ }
189
+ }
190
+
191
+ if (rows.length === 0) {
192
+ rows.push({
193
+ kind: "section",
194
+ key: "section:empty",
195
+ label: snapshot.items.length > 0 || snapshot.files.length > 0 ? "no matches" : "no todos",
196
+ detail:
197
+ snapshot.items.length > 0 || snapshot.files.length > 0
198
+ ? "clear the filter to see everything"
199
+ : "the agent records todos with todo_write; /todos add <path> subscribes a file",
200
+ muted: true,
201
+ })
202
+ } else if (countOpen(snapshot.items) + snapshot.files.reduce((sum, file) => sum + countOpen(file.items), 0) === 0) {
203
+ rows.push({ kind: "section", key: "section:done", label: "all todos done 🎉", muted: true })
204
+ }
205
+
206
+ return rows
207
+ }
208
+
209
+ /** Keeps a row key valid after a refresh; falls back to the first selectable row. */
210
+ export function clampCursor(rows: readonly Row[], key: string | undefined): string | undefined {
211
+ const selectable = rows.filter(isSelectable)
212
+ if (selectable.length === 0) return undefined
213
+ if (key && selectable.some((row) => row.key === key)) return key
214
+ return selectable[0]!.key
215
+ }
216
+
217
+ /** Moves by `delta` over selectable rows only, clamped to the ends. */
218
+ export function moveCursor(rows: readonly Row[], key: string | undefined, delta: number): string | undefined {
219
+ const selectable = rows.filter(isSelectable)
220
+ if (selectable.length === 0) return undefined
221
+ const current = selectable.findIndex((row) => row.key === key)
222
+ const index = current === -1 ? 0 : current
223
+ const next = Math.max(0, Math.min(selectable.length - 1, index + delta))
224
+ return selectable[next]!.key
225
+ }
226
+
227
+ export function rowIndex(rows: readonly Row[], key: string | undefined): number {
228
+ return key === undefined ? -1 : rows.findIndex((row) => row.key === key)
229
+ }
230
+
231
+ /** Scroll window that keeps the selected row visible within `height` rows. */
232
+ export function visibleWindow(
233
+ rows: readonly Row[],
234
+ key: string | undefined,
235
+ height: number,
236
+ ): { start: number; end: number } {
237
+ const size = Math.max(1, Math.floor(height))
238
+ if (rows.length <= size) return { start: 0, end: rows.length }
239
+ const index = Math.max(0, rowIndex(rows, key))
240
+ const start = Math.max(0, Math.min(index - Math.floor(size / 2), rows.length - size))
241
+ return { start, end: start + size }
242
+ }
243
+
244
+ /** Width-aware key hints for the footer (wide terminals get the long form). */
245
+ export function footerHints(width: number): string[] {
246
+ const wide = [
247
+ "↑↓ move",
248
+ "space toggle",
249
+ "enter collapse",
250
+ "n nudge",
251
+ "s pause",
252
+ "a add",
253
+ "d remove",
254
+ "r refresh",
255
+ "/ filter",
256
+ "f size",
257
+ "c clear",
258
+ "? help",
259
+ "esc close",
260
+ ]
261
+ if (width >= 120) return wide
262
+ if (width >= 80) return wide.slice(0, 8)
263
+ return ["↑↓", "space toggle", "n nudge", "/ filter", "? help", "esc"]
264
+ }
265
+
266
+ /**
267
+ * Rows the popover list body may occupy. The dialog hugs its content, so the
268
+ * body must be bounded or the overlay grows to the whole screen; the cap keeps
269
+ * it a popover and leaves room for the header and the key legend. `extraRows`
270
+ * accounts for conditional chrome (e.g. the filter line).
271
+ */
272
+ export function popoverBodyHeight(viewportHeight: number, extraRows = 0): number {
273
+ const total = Math.max(10, viewportHeight)
274
+ return Math.max(4, Math.min(total - 10 - Math.max(0, extraRows), 16))
275
+ }
276
+
277
+ export type DialogSizeName = "medium" | "large" | "xlarge"
278
+
279
+ /**
280
+ * Content width for the popover dialog. The host owns the dialog box, so the
281
+ * list sizes its rows from the render region instead and clamps for small
282
+ * terminals (a wider dialog just shows more of each row).
283
+ */
284
+ export function dialogWidth(size: DialogSizeName, viewportWidth: number): number {
285
+ const total = Math.max(40, viewportWidth)
286
+ const bySize =
287
+ size === "xlarge" ? total - 8 : size === "large" ? total - 16 : Math.min(72, Math.max(48, total - 24))
288
+ return Math.max(36, Math.min(bySize, 140))
289
+ }
@@ -0,0 +1,226 @@
1
+ // todo-supervisor — RPC contract + handler unit tests (aspect 11).
2
+ import { describe, expect, test } from "bun:test"
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import type { Plugin } from "@opencode/plugin"
5
+ import { MarkdownCache } from "./markdown"
6
+ import { DEFAULTS, type Options } from "./options"
7
+ import { registerTodoRpc, resolveMarkdownPath, TodoRpc } from "./rpc"
8
+ import type { TodoItem, TodoStore } from "./storage"
9
+ import type { Supervisor } from "./supervisor"
10
+
11
+ type SubRecord = { version: 1; sessionID: string; files: string[]; removed: string[]; updatedAt: number }
12
+
13
+ function makeWorld(seedItems: TodoItem[] = []) {
14
+ const todos = new Map<string, TodoItem[]>([["s1", seedItems]])
15
+ const subs = new Map<string, SubRecord>()
16
+ const calls: string[] = []
17
+ const emitted: { name: string; payload: Record<string, unknown> }[] = []
18
+
19
+ const store = {
20
+ getTodos: (sessionID: string) =>
21
+ todos.has(sessionID)
22
+ ? { version: 1 as const, sessionID, items: todos.get(sessionID)!, updatedAt: 0 }
23
+ : undefined,
24
+ refreshTodos: async (sessionID: string) =>
25
+ todos.has(sessionID)
26
+ ? { version: 1 as const, sessionID, items: todos.get(sessionID)!, updatedAt: 0 }
27
+ : undefined,
28
+ setTodos: async (sessionID: string, items: TodoItem[]) => {
29
+ todos.set(sessionID, items)
30
+ return { version: 1 as const, sessionID, items, updatedAt: 0 }
31
+ },
32
+ removeTodos: async (sessionID: string) => void todos.delete(sessionID),
33
+ loadSubs: async (sessionID: string) => subs.get(sessionID),
34
+ setSubs: async (sessionID: string, files: string[], removed: string[]) => {
35
+ const state: SubRecord = { version: 1, sessionID, files, removed, updatedAt: 0 }
36
+ subs.set(sessionID, state)
37
+ return state
38
+ },
39
+ } as unknown as TodoStore
40
+
41
+ const supervisor = {
42
+ snapshot: () => ({ registered: true, status: "idle" }),
43
+ notifyTodosChanged: (sessionID: string) => void calls.push(`changed ${sessionID}`),
44
+ forceNudge: (sessionID: string) => void calls.push(`nudge ${sessionID}`),
45
+ setEnabled: (sessionID: string, enabled: boolean) => void calls.push(`enabled ${sessionID} ${enabled}`),
46
+ } as unknown as Supervisor
47
+
48
+ const handlers: Record<string, (input: unknown) => Promise<unknown>> = {}
49
+ const ctx = {
50
+ rpc: {
51
+ register: async (_definition: unknown, registered: typeof handlers) => {
52
+ Object.assign(handlers, registered)
53
+ return {
54
+ events: {
55
+ emit: async (name: string, payload: Record<string, unknown>) => void emitted.push({ name, payload }),
56
+ },
57
+ dispose: async () => {},
58
+ }
59
+ },
60
+ },
61
+ } as unknown as Plugin.Context
62
+
63
+ return { store, supervisor, calls, emitted, handlers, ctx, todos, subs }
64
+ }
65
+
66
+ async function setup(seedItems: TodoItem[] = [], directory = "/proj", options: Options = DEFAULTS) {
67
+ const world = makeWorld(seedItems)
68
+ const logs: string[] = []
69
+ const reg = await registerTodoRpc(world.ctx, {
70
+ store: world.store,
71
+ supervisor: world.supervisor,
72
+ options,
73
+ markdown: new MarkdownCache(),
74
+ directory,
75
+ log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")),
76
+ })
77
+ return { ...world, reg, logs }
78
+ }
79
+
80
+ describe("RPC contract (§5.6)", () => {
81
+ test("contract exposes all methods and events", () => {
82
+ expect(TodoRpc.id).toBe("todo")
83
+ expect(Object.keys(TodoRpc.methods).sort()).toEqual([
84
+ "clear",
85
+ "list",
86
+ "nudge",
87
+ "setEnabled",
88
+ "subscribe",
89
+ "toggle",
90
+ "toggleFile",
91
+ "unsubscribe",
92
+ ])
93
+ expect(Object.keys(TodoRpc.events).sort()).toEqual(["nudged", "updated"])
94
+ })
95
+
96
+ test("list returns items, subscriptions placeholder and the supervisor snapshot", async () => {
97
+ const w = await setup([{ content: "one", status: "pending" }])
98
+ const result = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
99
+ expect((result.items as TodoItem[]).length).toBe(1)
100
+ expect(result.files).toEqual([])
101
+ expect((result.supervisor as Record<string, unknown>).registered).toBe(true)
102
+ })
103
+
104
+ test("clear drops the list, notifies the supervisor and emits updated once", async () => {
105
+ const w = await setup([{ content: "one", status: "pending" }])
106
+ await w.handlers.clear!({ sessionID: "s1" })
107
+ expect(w.todos.has("s1")).toBe(false)
108
+ expect(w.calls).toContain("changed s1")
109
+ expect(w.emitted).toEqual([{ name: "updated", payload: { sessionID: "s1" } }])
110
+ })
111
+
112
+ test("nudge and setEnabled delegate to the supervisor", async () => {
113
+ const w = await setup()
114
+ await w.handlers.nudge!({ sessionID: "s1" })
115
+ await w.handlers.setEnabled!({ sessionID: "s1", enabled: false })
116
+ expect(w.calls).toContain("nudge s1")
117
+ expect(w.calls).toContain("enabled s1 false")
118
+ })
119
+
120
+ test("subscribe resolves, dedupes and un-shadows; unsubscribe shadows", async () => {
121
+ const w = await setup()
122
+ await w.handlers.subscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
123
+ expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
124
+ await w.handlers.subscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
125
+ expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
126
+ // a config shadow (removed) is lifted by subscribing again
127
+ w.subs.set("s1", { version: 1, sessionID: "s1", files: [], removed: ["/proj/docs/TASKS.md"], updatedAt: 0 })
128
+ await w.handlers.subscribe!({ sessionID: "s1", path: "/proj/docs/TASKS.md" })
129
+ expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
130
+ expect(w.subs.get("s1")!.removed).toEqual([])
131
+ await w.handlers.unsubscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
132
+ expect(w.subs.get("s1")!.files).toEqual([])
133
+ expect(w.subs.get("s1")!.removed).toEqual(["/proj/docs/TASKS.md"])
134
+ expect(w.emitted.filter((e) => e.name === "updated").length).toBe(4)
135
+ })
136
+
137
+ test("toggle flips one item, notifies and emits updated", async () => {
138
+ const w = await setup([
139
+ { content: "one", status: "pending" },
140
+ { content: "two", status: "completed" },
141
+ ])
142
+ const first = (await w.handlers.toggle!({ sessionID: "s1", index: 0 })) as Record<string, unknown>
143
+ expect(first.status).toBe("completed")
144
+ expect(w.todos.get("s1")![0]!.status).toBe("completed")
145
+ const second = (await w.handlers.toggle!({ sessionID: "s1", index: 1 })) as Record<string, unknown>
146
+ expect(second.status).toBe("pending")
147
+ expect(w.calls.filter((call) => call === "changed s1").length).toBe(2)
148
+ expect(w.emitted.filter((event) => event.name === "updated").length).toBe(2)
149
+ await expect(w.handlers.toggle!({ sessionID: "s1", index: 9 })).rejects.toThrow(/range/)
150
+ })
151
+
152
+ test("toggleFile rewrites a subscribed file, refuses other paths and notifies", async () => {
153
+ const dir = "/tmp/opencode"
154
+ mkdirSync(dir, { recursive: true })
155
+ const file = `${dir}/rpc-toggle-${process.pid}.md`
156
+ const other = `${dir}/rpc-toggle-other-${process.pid}.md`
157
+ writeFileSync(file, "- [ ] one\n- [x] two\n")
158
+ writeFileSync(other, "- [ ] not subscribed\n")
159
+ const w = await setup([], dir)
160
+ await w.handlers.subscribe!({ sessionID: "s1", path: file })
161
+
162
+ const toggled = (await w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 1 })) as Record<string, unknown>
163
+ expect(toggled.state).toBe("completed")
164
+ expect(readFileSync(file, "utf8")).toContain("- [x] one")
165
+ await w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 2 })
166
+ expect(readFileSync(file, "utf8")).toContain("- [ ] two")
167
+ expect(w.calls.filter((call) => call === "changed s1").length).toBe(2)
168
+ expect(w.emitted.filter((event) => event.name === "updated").length).toBe(3) // subscribe + 2 toggles
169
+ await expect(w.handlers.toggleFile!({ sessionID: "s1", path: other, line: 1 })).rejects.toThrow(/not a subscribed/)
170
+ await expect(w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 9 })).rejects.toThrow(/no checklist item/)
171
+ })
172
+
173
+ test("list reports subscribed files with their existence", async () => {
174
+ const dir = "/tmp/opencode"
175
+ mkdirSync(dir, { recursive: true })
176
+ const file = `${dir}/rpc-todos-${process.pid}.md`
177
+ writeFileSync(file, "- [ ] one\n- [x] two\n")
178
+ const w = await setup([], dir)
179
+ await w.handlers.subscribe!({ sessionID: "s1", path: file })
180
+ const result = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
181
+ expect(result.files).toEqual([
182
+ {
183
+ path: file,
184
+ exists: true,
185
+ items: [
186
+ { content: "one", status: "pending", line: 1 },
187
+ { content: "two", status: "completed", line: 2 },
188
+ ],
189
+ },
190
+ ])
191
+ })
192
+
193
+ test("config markdownFiles merge into list.files and unsubscribe shadows them", async () => {
194
+ const options: Options = { ...DEFAULTS, markdownFiles: ["PLAN.md"] }
195
+ const w = await setup([], "/proj", options)
196
+ const before = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
197
+ expect(before.files).toEqual([{ path: "/proj/PLAN.md", exists: false, items: [] }])
198
+ await w.handlers.unsubscribe!({ sessionID: "s1", path: "PLAN.md" })
199
+ const after = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
200
+ expect(after.files).toEqual([])
201
+ })
202
+
203
+ test("rejects non-markdown paths and invalid input", async () => {
204
+ const w = await setup()
205
+ await expect(w.handlers.subscribe!({ sessionID: "s1", path: "notes.txt" })).rejects.toThrow(/markdown/)
206
+ await expect(w.handlers.list!({})).rejects.toThrow(/sessionID/)
207
+ await expect(w.handlers.setEnabled!({ sessionID: "s1", enabled: "yes" })).rejects.toThrow(/boolean/)
208
+ })
209
+
210
+ test("handler failures are logged and rethrown", async () => {
211
+ const w = await setup([{ content: "one", status: "pending" }])
212
+ const failing = w.store as unknown as { removeTodos: () => Promise<void> }
213
+ failing.removeTodos = async () => {
214
+ throw new Error("storage offline")
215
+ }
216
+ await expect(w.handlers.clear!({ sessionID: "s1" })).rejects.toThrow(/storage offline/)
217
+ expect(w.logs.some((line) => line.includes("rpc clear failed"))).toBe(true)
218
+ })
219
+
220
+ test("resolveMarkdownPath resolves against the directory and accepts absolute paths", () => {
221
+ expect(resolveMarkdownPath("/proj", "TODO.md")).toBe("/proj/TODO.md")
222
+ expect(resolveMarkdownPath("/proj", "/abs/PLAN.markdown")).toBe("/abs/PLAN.markdown")
223
+ expect(resolveMarkdownPath("/proj", "UPPER.MD")).toBe("/proj/UPPER.MD")
224
+ expect(() => resolveMarkdownPath("/proj", "x.txt")).toThrow()
225
+ })
226
+ })