@comfanion/usethis_todo 0.1.16-dev.8 → 0.1.16

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.
Files changed (3) hide show
  1. package/index.ts +83 -78
  2. package/package.json +1 -1
  3. package/tools.ts +119 -16
package/index.ts CHANGED
@@ -2,15 +2,19 @@ import type { Plugin } from "@opencode-ai/plugin"
2
2
  import path from "path"
3
3
  import fs from "fs/promises"
4
4
 
5
- import { write, read, read_five, read_by_id, setNativeStorageBase, readTodos } from "./tools"
5
+ import { write, read, read_five, read_by_id, setNativeStorageBase, setClientStorage } from "./tools"
6
6
 
7
- interface TodoPruneState {
8
- lastToolCallId: string | null
9
- }
10
-
11
- const pruneStates = new Map<string, TodoPruneState>()
7
+ const TODO_TOOLS = new Set([
8
+ "usethis_todo_write",
9
+ "usethis_todo_read",
10
+ "usethis_todo_read_five",
11
+ "usethis_todo_read_by_id",
12
+ ])
12
13
 
13
14
  const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
15
+ // Set client storage API for native Storage operations
16
+ setClientStorage(client)
17
+
14
18
  // Resolve the authoritative state path from OpenCode server (non-blocking).
15
19
  // Must NOT await — server may block until plugin init completes → deadlock.
16
20
  client.path.get().then((pathInfo: any) => {
@@ -27,111 +31,112 @@ const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
27
31
  }
28
32
 
29
33
  return {
30
- // Keep only the latest tool output for our TODO tools (context-efficient)
31
- // Previous todo tool calls are replaced with [TODO pruned]
32
- // DISABLED for now — lastToolCallId resets after reload, breaks pruning logic
33
- /*
34
+ // Prune old TODO tool outputs + old "## TODO" snapshot text parts.
35
+ // Keeps only the LAST call of each tool and the LAST snapshot.
34
36
  "experimental.chat.messages.transform": async (_input: unknown, output: { messages: any[] }) => {
35
37
  const messages = output.messages || []
36
- const sessionID = messages[0]?.info?.sessionID
37
-
38
- if (!sessionID) return
39
-
40
- const state = pruneStates.get(sessionID)
41
- if (!state?.lastToolCallId) return
42
-
43
- const prunedToolNames = new Set([
44
- "usethis_todo_write",
45
- "usethis_todo_read",
46
- "usethis_todo_read_five",
47
- "usethis_todo_read_by_id",
48
- // todowrite is NOT pruned — required for TUI rendering + sidebar sync
49
- ])
38
+ if (messages.length === 0) return
50
39
 
51
- // Collect all TODO-related tool parts
52
- const toolParts: { part: any; isLast: boolean }[] = []
40
+ // 1. Collect tool parts and snapshot text parts
41
+ const toolParts: any[] = []
42
+ const snapshotParts: any[] = []
53
43
 
54
44
  for (const msg of messages) {
55
45
  for (const part of (msg.parts || [])) {
56
- if (part.type === "tool" && prunedToolNames.has(part.tool) && part.state?.status === "completed") {
57
- toolParts.push({ part, isLast: part.callID === state.lastToolCallId })
46
+ if (
47
+ part.type === "tool" &&
48
+ TODO_TOOLS.has(part.tool) &&
49
+ part.state?.status === "completed"
50
+ ) {
51
+ toolParts.push(part)
52
+ }
53
+ if (
54
+ part.type === "text" &&
55
+ typeof part.text === "string" &&
56
+ part.text.startsWith("## TODO")
57
+ ) {
58
+ snapshotParts.push(part)
59
+ }
60
+ }
61
+ }
62
+
63
+ // 2. Collect parts to remove (old tool calls + old snapshots)
64
+ const partsToRemove = new Set<any>()
65
+
66
+ // Keep only last call per tool — remove all older ones entirely
67
+ if (toolParts.length > 1) {
68
+ const lastByTool = new Map<string, any>()
69
+ for (const part of toolParts) {
70
+ lastByTool.set(part.tool, part)
71
+ }
72
+ for (const part of toolParts) {
73
+ if (part !== lastByTool.get(part.tool)) {
74
+ partsToRemove.add(part)
58
75
  }
59
76
  }
60
77
  }
61
78
 
62
- if (toolParts.length === 0) return
79
+ // Keep only last "## TODO" snapshot — remove all older ones
80
+ if (snapshotParts.length > 1) {
81
+ const lastSnapshot = snapshotParts[snapshotParts.length - 1]
82
+ for (const part of snapshotParts) {
83
+ if (part !== lastSnapshot) {
84
+ partsToRemove.add(part)
85
+ }
86
+ }
87
+ }
63
88
 
64
- // Prune old tool parts clear BOTH input and output, keep only last
65
- for (const { part, isLast } of toolParts) {
66
- if (!isLast) {
67
- part.state.output = "[TODO pruned]"
68
- if (part.state.input) part.state.input = {}
89
+ // 3. Remove collected parts from messages
90
+ if (partsToRemove.size > 0) {
91
+ for (const msg of messages) {
92
+ if (!Array.isArray(msg.parts)) continue
93
+ msg.parts = msg.parts.filter((p: any) => !partsToRemove.has(p))
69
94
  }
95
+ // Remove empty messages
96
+ output.messages = messages.filter((msg: any) => (msg.parts || []).length > 0)
70
97
  }
71
98
  },
72
- */
73
99
 
74
100
  tool: {
75
- // Our enhanced tools — AI uses these for graph/dependencies
76
101
  usethis_todo_write: write,
77
102
  usethis_todo_read: read,
78
103
  usethis_todo_read_five: read_five,
79
104
  usethis_todo_read_by_id: read_by_id,
80
-
81
- // Native override — emulates todowrite for sidebar refresh + TUI checklist
82
- // Reads the data our write already stored and returns native JSON format.
83
- // tool.execute.after on usethis_todo_write auto-invokes this via description hint.
84
- todowrite: {
85
- description: write.description, // Use same description as write
86
- args: write.args, // Use same args as write
87
- async execute(args: any, context: any) {
88
- // If args provided, perform write first
89
- if (args.todos && args.todos.length > 0) {
90
- await write.execute(args, context)
91
- }
92
-
93
- // Then return native JSON format
94
- const todos = await readTodos(context.sessionID, context.directory)
95
- return JSON.stringify(todos.map(t => ({
96
- id: t.id,
97
- content: t.content,
98
- status: t.status,
99
- priority: t.priority,
100
- })))
101
- },
102
- },
103
105
  },
104
106
 
105
- // Set nicer titles in TUI + track prune state
106
- "tool.execute.after": async (input, output) => {
107
- const ourTools = new Set(["usethis_todo_write", "usethis_todo_read", "usethis_todo_read_five", "usethis_todo_read_by_id", "todowrite"])
108
- if (!ourTools.has(input.tool)) return
109
-
110
- // Update prune state with latest call ID (only for our custom tools, not todowrite)
111
- if (input.tool.startsWith("usethis_todo_")) {
112
- const sessionID = input.sessionID
113
- if (sessionID) {
114
- const state = pruneStates.get(sessionID) || { lastToolCallId: null }
115
- state.lastToolCallId = input.callID
116
- pruneStates.set(sessionID, state)
117
- }
118
- }
107
+ // Publish TODO output as a text message in chat via session.prompt().
108
+ // TUI GenericTool does not render output this injects a visible "## TODO" text part.
109
+ // Also prunes old "## TODO" snapshots in messages.transform.
110
+ "tool.execute.after": async (input: any, output: any) => {
111
+ if (!TODO_TOOLS.has(input.tool)) return
119
112
 
120
113
  const out = output.output || ""
121
114
 
122
- // Set a nicer title in TUI
115
+ // Set title
123
116
  if (input.tool === "usethis_todo_write") {
124
117
  const match = out.match(/\[(\d+)\/(\d+) done/)
125
- output.title = match ? `TODO: ${match[2]} tasks` : "TODO updated"
118
+ output.title = match ? `TODO [${match[1]}/${match[2]} done]` : "TODO updated"
126
119
  } else if (input.tool === "usethis_todo_read") {
127
120
  const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
128
- output.title = match ? `TODO [${match[1]}/${match[2]} done]` : "TODO list"
121
+ output.title = match ? `TODO [${match[1]}/${match[2]} done, ${match[3]} wip]` : "TODO list"
129
122
  } else if (input.tool === "usethis_todo_read_five") {
130
123
  output.title = "Next 5 tasks"
131
124
  } else if (input.tool === "usethis_todo_read_by_id") {
132
125
  output.title = "Task details"
133
- } else if (input.tool === "todowrite") {
134
- output.title = "Sidebar sync"
126
+ }
127
+
128
+ // Publish snapshot into chat so user sees it in TUI
129
+ const text = ["## TODO", "", out].join("\n")
130
+ try {
131
+ await client?.session?.prompt?.({
132
+ path: { id: input.sessionID },
133
+ body: {
134
+ noReply: true,
135
+ parts: [{ type: "text", text }],
136
+ },
137
+ })
138
+ } catch {
139
+ // non-fatal — prompt API may not be available
135
140
  }
136
141
  },
137
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/usethis_todo",
3
- "version": "0.1.16-dev.8",
3
+ "version": "0.1.16",
4
4
  "description": "OpenCode plugin: enhanced TODO tools (dual storage + dependency graph)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/tools.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * usethis_todo_read_by_id({ id }) - read task by id with its blockers
9
9
  *
10
10
  * Storage:
11
- * Unified: Native OpenCode storage (TUI compatible)
12
- * Path resolved via client.path.get()
11
+ * Dual: Native OpenCode storage (via client.storage) + Local file backup (.opencode/session-todo/)
12
+ * Native storage uses await client.storage.write(["todo", sessionID], todos)
13
13
  *
14
14
  * Features:
15
15
  * - Stores FULL enhanced schema (blockedBy, priority, etc) in the native file
@@ -23,6 +23,59 @@ import path from "path"
23
23
  import os from "os"
24
24
  import fs from "fs/promises"
25
25
 
26
+ // ============================================================================
27
+ // Client API Wrapper (Storage + Bus)
28
+ // ============================================================================
29
+
30
+ let _client: any = null
31
+
32
+ /**
33
+ * Set the OpenCode client API.
34
+ * Called during plugin initialization with the full client object.
35
+ */
36
+ export function setClientStorage(client: any): void {
37
+ _client = client
38
+ }
39
+
40
+ /**
41
+ * Write to native OpenCode storage.
42
+ * Equivalent to: Storage.write(["todo", sessionID], todos)
43
+ */
44
+ async function writeNativeStorage(key: string[], value: any): Promise<void> {
45
+ if (!_client?.storage?.write) return
46
+ try {
47
+ await _client.storage.write(key, value)
48
+ } catch {
49
+ // ignore — native storage not available
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Read from native OpenCode storage.
55
+ * Equivalent to: Storage.read<T>(["todo", sessionID])
56
+ */
57
+ async function readNativeStorage<T>(key: string[]): Promise<T | null> {
58
+ if (!_client?.storage?.read) return null
59
+ try {
60
+ return await _client.storage.read(key)
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Publish event to OpenCode bus.
68
+ * Equivalent to: Bus.publish(Event.Updated, { sessionID, todos })
69
+ */
70
+ async function publishBusEvent(event: string, data: any): Promise<void> {
71
+ if (!_client?.bus?.publish) return
72
+ try {
73
+ await _client.bus.publish(event, data)
74
+ } catch {
75
+ // ignore — bus not available from plugins
76
+ }
77
+ }
78
+
26
79
  // ============================================================================
27
80
  // Types
28
81
  // ============================================================================
@@ -178,8 +231,6 @@ async function getStoragePaths(sid: string): Promise<string[]> {
178
231
  // Mapping Logic (Enhanced <-> Native)
179
232
  // ============================================================================
180
233
 
181
-
182
-
183
234
  function toNativePriority(priority: string): string {
184
235
  switch (priority) {
185
236
  case "CRIT": return "high"
@@ -195,6 +246,21 @@ function toNativePriority(priority: string): string {
195
246
  // ============================================================================
196
247
 
197
248
  export async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
249
+ // 1. Try Native OpenCode Storage first (primary)
250
+ try {
251
+ const nativeData = await readNativeStorage<Todo[]>(["todo", sid])
252
+ if (Array.isArray(nativeData) && nativeData.length > 0) {
253
+ return nativeData.map((t: any) => {
254
+ const realStatus = t.usethisStatus || t.status
255
+ const realPriority = t.usethisPriority || t.priority
256
+ return normalizeTodo({ ...t, status: realStatus, priority: realPriority })
257
+ })
258
+ }
259
+ } catch {
260
+ // fallback to file storage
261
+ }
262
+
263
+ // 2. Fallback to file storage
198
264
  try {
199
265
  const storagePaths = await getStoragePaths(sid)
200
266
  for (const storagePath of storagePaths) {
@@ -202,13 +268,14 @@ export async function readTodos(sid: string, directory?: string): Promise<Todo[]
202
268
  const raw = JSON.parse(await fs.readFile(storagePath, "utf-8"))
203
269
  if (Array.isArray(raw)) {
204
270
  return raw.map((t: any) => {
205
- // Restore "ready" status if it was saved
206
271
  const realStatus = t.usethisStatus || t.status
207
272
  const realPriority = t.usethisPriority || t.priority
208
273
  return normalizeTodo({ ...t, status: realStatus, priority: realPriority })
209
274
  })
210
275
  }
211
- } catch { continue }
276
+ } catch {
277
+ continue
278
+ }
212
279
  }
213
280
  return []
214
281
  } catch {
@@ -224,12 +291,12 @@ function getEnhancedPath(sid: string, directory?: string): string {
224
291
  async function writeTodos(todos: Todo[], sid: string, directory?: string): Promise<void> {
225
292
  const storagePaths = await getStoragePaths(sid)
226
293
  const enhancedPath = getEnhancedPath(sid, directory)
227
-
294
+
228
295
  const storageTodos = todos.map(t => {
229
296
  // Status is already native — map "ready" to "in_progress" for native storage
230
297
  const nativeStatus = t.status === "ready" ? "in_progress" : t.status
231
298
  const nativePriority = toNativePriority(t.priority)
232
-
299
+
233
300
  return {
234
301
  ...t,
235
302
  status: nativeStatus,
@@ -242,27 +309,35 @@ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promi
242
309
 
243
310
  const json = JSON.stringify(storageTodos, null, 2)
244
311
 
245
- // 1. Write to Global Storages (for Sidebar)
312
+ // 1. Write to Native OpenCode Storage (primary)
313
+ try {
314
+ await writeNativeStorage(["todo", sid], storageTodos)
315
+ } catch {
316
+ // ignore
317
+ }
318
+
319
+ // 2. Write to Global Storages (for Sidebar - fallback)
246
320
  await Promise.allSettled(
247
321
  storagePaths.map(async (p) => {
248
322
  try {
249
323
  await fs.mkdir(path.dirname(p), { recursive: true })
250
324
  await fs.writeFile(p, json, "utf-8")
251
- await logAction(directory || "", "debug", `Writing to global: ${p}`)
252
- } catch (e) {
253
- await logAction(directory || "", "error", `Global write failed for ${p}: ${String(e)}`)
325
+ } catch {
326
+ // ignore
254
327
  }
255
328
  })
256
329
  )
257
330
 
258
- // 2. Write to Local Storage (for User visibility/Git)
331
+ // 3. Write to Local Storage (for User visibility/Git - backup)
259
332
  try {
260
333
  await fs.mkdir(path.dirname(enhancedPath), { recursive: true })
261
334
  await fs.writeFile(enhancedPath, json, "utf-8")
262
- await logAction(directory || "", "debug", `Writing to local: ${enhancedPath}`)
263
- } catch (e) {
264
- await logAction(directory || "", "error", `Local write failed: ${String(e)}`)
335
+ } catch {
336
+ // ignore
265
337
  }
338
+
339
+ // 4. Publish "todo.updated" event to Bus
340
+ await publishBusEvent("todo.updated", { sessionID: sid, todos: storageTodos })
266
341
  }
267
342
 
268
343
  // ============================================================================
@@ -565,12 +640,40 @@ Set merge: false to replace the entire list.`,
565
640
  const merged = [...byId.values()]
566
641
  await writeTodos(merged, context.sessionID, context.directory)
567
642
  await logAction(context.directory, "write-merge", `Merged ${args.todos.length} task(s) in session ${context.sessionID}`)
643
+
644
+ // Try to trigger sidebar update via metadata
645
+ try {
646
+ const nativeTodos = merged.map(t => ({
647
+ id: t.id,
648
+ content: t.content,
649
+ status: t.status === "ready" ? "in_progress" : t.status,
650
+ priority: toNativePriority(t.priority),
651
+ }))
652
+ await context.metadata({ todos: nativeTodos })
653
+ } catch {
654
+ // metadata not available — sidebar won't update
655
+ }
656
+
568
657
  return `✅ Updated ${args.todos.length} task(s)\n\n${formatGraph(analyzeGraph(merged))}`
569
658
  } else {
570
659
  // REPLACE mode: replace entire list
571
660
  const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
572
661
  await writeTodos(todos, context.sessionID, context.directory)
573
662
  await logAction(context.directory, "write", `Created/Updated ${todos.length} tasks in session ${context.sessionID}`)
663
+
664
+ // Try to trigger sidebar update via metadata
665
+ try {
666
+ const nativeTodos = todos.map(t => ({
667
+ id: t.id,
668
+ content: t.content,
669
+ status: t.status === "ready" ? "in_progress" : t.status,
670
+ priority: toNativePriority(t.priority),
671
+ }))
672
+ await context.metadata({ todos: nativeTodos })
673
+ } catch {
674
+ // metadata not available — sidebar won't update
675
+ }
676
+
574
677
  return `✅ Updated ${todos.length} task(s)\n\n${formatGraph(analyzeGraph(todos))}`
575
678
  }
576
679
  },