Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ OpenCode V2 removed the built-in todo tools. This plugin brings them back as a s

- **`todowrite`** — create or replace the todo list for the current session.
- **`todoread`** — read the current list back.
- **`/todo`** — post the current list into the conversation.

Lists are stored per session and a compact summary is injected into the session context while tasks are still open — at every user turn and whenever the list changes mid-run — so the model keeps track of them across long conversations and context compaction. A constant reminder line asks the model to update statuses as steps finish (see `Update reminders` below).

Expand Down Expand Up @@ -50,7 +51,36 @@ Example output:
4. [-] Update changelog
```

`todowrite` always receives the complete list and replaces the previous one. Passing `{"todos": []}` clears it.
`todowrite` always receives the complete list and replaces the previous one. Passing `{"todos": []}` clears it. Lists accept up to 100 items of 500 characters each.

Run `/todo` to post the current list into the conversation. The command reads the newest `todowrite` from the session log (falling back to this plugin's own storage), so a list written by another tool shows up too, and sends the report — counts, current task, full list — as your next message, which the assistant sees and answers.

## Auto todos

A project can seed every session with a checklist: `auto-todos.json` at the repository root or under `.opencode/`:

```json
{
"todos": [
{ "content": "Run the test suite", "priority": "high", "always": true },
{ "content": "Update the changelog" }
],
"deduplicate": true
}
```

Each entry is merged into the list on every `todowrite`:

| Field | Effect |
| --- | --- |
| `content` | Required task text; entries over 500 characters are dropped |
| `priority` | Optional: `high`, `medium`, `low` |
| `always` | `true` adds the item to every list; `false` (default) adds it only when the incoming list is empty |
| `match` | Only inject when an existing item contains this text (case-insensitive) |
| `order` | `"first"` or a 0-based index; omitted appends |
| `deduplicate` | Default `true`: skip items whose content contains, or is contained by, an existing item |

Injection is best-effort: a missing, unreadable or invalid file leaves the model's list untouched, and the merged list never exceeds the `todowrite` limits (100 items, 500 characters per entry).

## Sidebar

Expand Down
162 changes: 162 additions & 0 deletions src/auto-todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import type { Plugin } from "@opencode/plugin"
import { MAX_TODO_CONTENT_LENGTH, MAX_TODOS, TODO_PRIORITIES, type Todo, type TodoPriority } from "./todos.js"

/**
* Config-driven todo injection (opencode-auto-todos parity, serkanalgur).
*
* A project opts in with `auto-todos.json` at its root or under
* `.opencode/`:
*
* ```json
* {
* "todos": [{ "content": "Run the test suite", "priority": "high", "order": "first" }],
* "deduplicate": true
* }
* ```
*
* Entry fields: `content` (required), `priority` (optional), `match`
* (optional: only inject when an existing item contains this string,
* case-insensitive), `order` (`"first"` or a 0-based index, default append),
* `always` (default false: inject only when the incoming list is empty).
*
* Entries obey the same limits as `todowrite`: content over
* `MAX_TODO_CONTENT_LENGTH` is dropped and the merged list never grows past
* `MAX_TODOS`.
*
* Best-effort throughout: no config file, an unreadable file, or a lookup
* failure means the model's list passes through untouched.
*/

export type AutoTodoEntry = {
content: string
priority?: TodoPriority
match?: string
order?: "first" | number
always?: boolean
}

export type AutoTodosConfig = {
todos: Array<AutoTodoEntry>
deduplicate?: boolean
}

/** Returns a clean config or undefined when the value is unusable. */
export function normalizeAutoTodosConfig(value: unknown): AutoTodosConfig | undefined {
if (value === null || typeof value !== "object") return undefined
const raw = value as { todos?: unknown; deduplicate?: unknown }
if (!Array.isArray(raw.todos)) return undefined
const todos: Array<AutoTodoEntry> = []
for (const entry of raw.todos) {
if (entry === null || typeof entry !== "object") continue
const item = entry as { content?: unknown; priority?: unknown; match?: unknown; order?: unknown; always?: unknown }
if (typeof item.content !== "string" || !item.content.trim()) continue
const content = item.content.trim()
if (content.length > MAX_TODO_CONTENT_LENGTH) continue
const out: AutoTodoEntry = { content }
if (TODO_PRIORITIES.includes(item.priority as TodoPriority)) {
out.priority = item.priority as TodoPriority
}
if (typeof item.match === "string" && item.match.trim()) {
out.match = item.match.trim()
}
if (item.order === "first" || (typeof item.order === "number" && Number.isInteger(item.order) && item.order >= 0)) {
out.order = item.order
}
if (typeof item.always === "boolean") {
out.always = item.always
}
todos.push(out)
}
if (todos.length === 0) return undefined
const config: AutoTodosConfig = { todos }
if (typeof raw.deduplicate === "boolean") {
config.deduplicate = raw.deduplicate
}
return config
}

/** Project root first, then `.opencode/`. Never throws. */
export async function readAutoTodosConfig(directory: string): Promise<AutoTodosConfig | undefined> {
for (const file of [join(directory, "auto-todos.json"), join(directory, ".opencode", "auto-todos.json")]) {
try {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"))
const config = normalizeAutoTodosConfig(parsed)
if (config) return config
} catch {
// Missing, unreadable, or invalid — try the next candidate.
}
}
return undefined
}

function contains(haystack: string, needle: string): boolean {
return haystack.toLowerCase().includes(needle.toLowerCase())
}

/**
* Merges config entries into a validated list. Pure: never mutates inputs.
* Returns the merged list plus how many entries were injected.
*/
export function applyAutoTodos(
todos: Array<Todo>,
config: AutoTodosConfig | undefined,
): { todos: Array<Todo>; injected: number } {
if (!config || config.todos.length === 0) return { todos, injected: 0 }
const deduplicate = config.deduplicate ?? true
const merged = todos.map((t) => ({ ...t }))
const incomingEmpty = merged.length === 0
const firsts: Array<Todo> = []
let injected = 0
for (const entry of config.todos) {
if (!entry.always && !incomingEmpty) continue
if (firsts.length + merged.length >= MAX_TODOS) break
const seen = [...firsts, ...merged]
if (entry.match && !seen.some((t) => contains(t.content, entry.match as string))) continue
if (deduplicate && seen.some((t) => contains(t.content, entry.content) || contains(entry.content, t.content))) {
continue
}
const todo: Todo = { content: entry.content, status: "pending" }
if (entry.priority) todo.priority = entry.priority
if (entry.order === "first") {
firsts.push(todo)
} else if (typeof entry.order === "number") {
merged.splice(Math.min(entry.order, merged.length), 0, todo)
} else {
merged.push(todo)
}
injected++
}
return { todos: [...firsts, ...merged], injected }
}

/** The session's working directory via the typed client. Best-effort. */
export async function resolveSessionDirectory(
ctx: Plugin.Context,
sessionID: string,
): Promise<string | undefined> {
const info = await ctx.session.get({ sessionID })
const directory = info?.location?.directory
return typeof directory === "string" && directory ? directory : undefined
}

/**
* Full injection pass for the todowrite path. Returns the list to store:
* the input unchanged when there is nothing to inject or anything fails.
*/
export async function withAutoTodos(
ctx: Plugin.Context,
sessionID: string,
todos: Array<Todo>,
): Promise<Array<Todo>> {
try {
const directory = await resolveSessionDirectory(ctx, sessionID)
if (!directory) return todos
const config = await readAutoTodosConfig(directory)
if (!config) return todos
return applyAutoTodos(todos, config).todos
} catch {
return todos
}
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "./todos.js"
import { applyTodoWrite, findItem, itemTimeLabel, timingSummary } from "./timing.js"
import { TODO_SETTINGS_KEY, TodolistRpc, normalizeTodoSettings } from "./rpc.js"
import { registerTodoCommand } from "./todo-command.js"
import { withAutoTodos } from "./auto-todos.js"

export * from "./todos.js"

Expand Down Expand Up @@ -83,7 +85,7 @@ const plugin: Plugin.Plugin = {
input: TODOS_INPUT_SCHEMA,
options: { codemode: false },
execute: async (input, context) => {
const todos = normalizeTodos(input)
const todos = await withAutoTodos(ctx, context.sessionID, normalizeTodos(input))
const at = Date.now()
const key = storageKey(context.sessionID)
const previous = parseTodoRecord(await ctx.storage.get(key))
Expand Down Expand Up @@ -112,6 +114,8 @@ const plugin: Plugin.Plugin = {
})
})

const todoCommand = await registerTodoCommand(ctx)

let settings = normalizeTodoSettings(await ctx.storage.get(TODO_SETTINGS_KEY))

const rpc = await ctx.rpc.register(TodolistRpc, {
Expand Down Expand Up @@ -203,6 +207,7 @@ const plugin: Plugin.Plugin = {
await compaction.dispose()
await rpc.dispose()
await tools.dispose()
await todoCommand.dispose()
}
},
}
Expand Down
64 changes: 64 additions & 0 deletions src/todo-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Plugin } from "@opencode/plugin"
import { parseTodoRecord, renderTodos, storageKey, type Todo } from "./todos.js"
import { latestTodosFromMessages } from "./tui-data.js"

const TODO_COMMAND_DESCRIPTION =
"Show the current session todo list: completed items marked, current task identified. Same list the todowrite/todoread tools manage."

/**
* Newest completed `todowrite` in the session log wins: `todowrite` replaces the
* whole list, so the newest completed call IS the current list. The reader is
* reused from tui-data (the same one the TUI strip relies on), so the command
* and the strip can never disagree about what the latest list is.
*/
export async function todosFromMessageSources(
ctx: Plugin.Context,
sessionID: string,
): Promise<Array<Todo> | undefined> {
const todos = latestTodosFromMessages(await ctx.session.context({ sessionID }))
if (todos) return todos
// Same-plugin storage is the fallback for a log with no completed todowrite.
// Returns undefined - never [] - when neither source has a list.
const record = parseTodoRecord(await ctx.storage.get(storageKey(sessionID)))
return record?.todos
}

/** On-demand report for /todo: counts, current task, full list. */
export function renderTodoReport(todos: Array<Todo>): string {
if (todos.length === 0) {
return "Todo [0/0]: the todo list is empty."
}
const done = todos.filter((t) => t.status === "completed").length
const open = todos.filter((t) => t.status === "pending" || t.status === "in_progress").length
const current = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending")
const currentLine = current
? `Current task: ${current.content}`
: "Current task: none - every item is completed or cancelled."
return `Todo [${done}/${todos.length}] - ${open} open\n${currentLine}\n${renderTodos(todos)}`
}

/** Registers the `/todo` command. Returns a `{ dispose }` handle for setup teardown. */
export async function registerTodoCommand(
ctx: Plugin.Context,
): Promise<{ dispose: () => Promise<void> }> {
const noop = async (): Promise<void> => {}
if (!ctx.command || typeof ctx.command.transform !== "function") {
return { dispose: noop }
}
const command = await ctx.command.transform((editor) => {
editor.add({
name: "todo",
description: TODO_COMMAND_DESCRIPTION,
execute: async ({ sessionID, prompt, delivery }) => {
const todos = await todosFromMessageSources(ctx, sessionID)
await ctx.session.prompt({
...prompt,
sessionID,
text: todos ? renderTodoReport(todos) : "No todo list found for this session.",
delivery,
})
},
})
})
return { dispose: () => command.dispose() }
}
10 changes: 10 additions & 0 deletions src/todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const TODO_PRIORITIES: ReadonlyArray<TodoPriority> = ["high", "medium", "

export const STORAGE_PREFIX = "todos/"

/** Hard caps so a runaway or adversarial list can't blow up the request. */
export const MAX_TODOS = 100
export const MAX_TODO_CONTENT_LENGTH = 500

export const STATUS_MARK: Record<TodoStatus, string> = {
pending: "[ ]",
in_progress: "[•]",
Expand All @@ -43,6 +47,9 @@ export function normalizeTodos(input: unknown): Array<Todo> {
if (!Array.isArray(todos)) {
throw new Error("`todos` must be an array")
}
if (todos.length > MAX_TODOS) {
throw new Error(`at most ${MAX_TODOS} todos per list (got ${todos.length})`)
}
return todos.map((entry, index) => {
if (entry === null || typeof entry !== "object") {
throw new Error(`todo #${index + 1} must be an object`)
Expand All @@ -52,6 +59,9 @@ export function normalizeTodos(input: unknown): Array<Todo> {
if (!content) {
throw new Error(`todo #${index + 1} requires a non-empty \`content\` string`)
}
if (content.length > MAX_TODO_CONTENT_LENGTH) {
throw new Error(`todo #${index + 1} \`content\` is over ${MAX_TODO_CONTENT_LENGTH} characters`)
}
if (!TODO_STATUSES.includes(item.status as TodoStatus)) {
throw new Error(`todo #${index + 1} has invalid \`status\` (expected one of: ${TODO_STATUSES.join(", ")})`)
}
Expand Down
Loading
Loading