@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.
- package/index.ts +83 -78
- package/package.json +1 -1
- 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,
|
|
5
|
+
import { write, read, read_five, read_by_id, setNativeStorageBase, setClientStorage } from "./tools"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
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
|
|
52
|
-
const toolParts:
|
|
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 (
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
|
134
|
-
|
|
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
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
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
263
|
-
|
|
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
|
},
|