@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/LICENSE +21 -0
- package/README.md +366 -2
- package/index.ts +3 -0
- package/package.json +61 -4
- package/src/index.ts +186 -0
- package/src/log.ts +77 -0
- package/src/machine.test.ts +779 -0
- package/src/markdown.test.ts +135 -0
- package/src/markdown.ts +159 -0
- package/src/options.test.ts +42 -0
- package/src/options.ts +100 -0
- package/src/panel.test.ts +193 -0
- package/src/panel.ts +289 -0
- package/src/rpc.test.ts +226 -0
- package/src/rpc.ts +290 -0
- package/src/storage.test.ts +101 -0
- package/src/storage.ts +186 -0
- package/src/supervisor.test.ts +37 -0
- package/src/supervisor.ts +1269 -0
- package/src/tools.test.ts +260 -0
- package/src/tools.ts +405 -0
- package/src/tui.tsx +658 -0
- package/tui.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 n0k0
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,367 @@
|
|
|
1
|
-
#
|
|
1
|
+
# todo-supervisor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [OpenCode](https://opencode.ai) **V2** plugin that gives every session a real
|
|
4
|
+
todo list โ and then keeps the agent working on it. A live progress strip in the
|
|
5
|
+
composer (`๐ โโโโโโโโ 8/17 open`) tracks the list.
|
|
6
|
+
|
|
7
|
+
- `todo_write` / `todo_read` tools (V2 ships none), stored per session;
|
|
8
|
+
- a **supervisor** that re-prompts an idle session while its list still has open
|
|
9
|
+
items โ and stays quiet while the agent is actually working (thinking,
|
|
10
|
+
preparing a tool, a running tool or `task` subagent, background shells, child
|
|
11
|
+
sessions), nudging only once that work goes silent;
|
|
12
|
+
- **Markdown mode**: subscribe any number of markdown files whose GFM checklists
|
|
13
|
+
(`- [ ]`) count as todos โ and check them off from the panel;
|
|
14
|
+
- a TUI strip + popover panel, slash command, palette entries, and toasts.
|
|
15
|
+
|
|
16
|
+
Nudges are delivered as synthetic session input, so the model sees them as normal
|
|
17
|
+
instructions. Every nudge is self-contained: counts, the open items (with
|
|
18
|
+
priorities), a short explainer of why it fired, and how to finish.
|
|
19
|
+
|
|
20
|
+
Tested against OpenCode `2.0.24`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
opencode plugin add @hacky.software/opencode-todo-supervisor@0.3.2
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This also loads the bundled TUI entry. To pass options, use the object form:
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
// ~/.config/opencode/opencode.json
|
|
32
|
+
{
|
|
33
|
+
"$schema": "https://opencode.ai/config.json",
|
|
34
|
+
"plugins": [
|
|
35
|
+
{
|
|
36
|
+
"package": "@hacky.software/opencode-todo-supervisor",
|
|
37
|
+
"options": {
|
|
38
|
+
"stallMinutes": 5,
|
|
39
|
+
"settleMs": 5000,
|
|
40
|
+
"minNudgeIntervalMs": 45000,
|
|
41
|
+
"maxNoProgressNudges": 5,
|
|
42
|
+
"injectTodoContext": true,
|
|
43
|
+
"markdownFiles": ["PLAN.md", "TODO.md"],
|
|
44
|
+
"scope": "project"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Development
|
|
52
|
+
|
|
53
|
+
To run from a checkout instead, point the config at the directory and run
|
|
54
|
+
`bun install` once inside it (the loader does not install dependencies for
|
|
55
|
+
local-path entries). Config content and plugin source edits are hot-reloaded.
|
|
56
|
+
|
|
57
|
+
```jsonc
|
|
58
|
+
{
|
|
59
|
+
"plugins": [{ "package": "/absolute/path/to/todo" }]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Logs: the plugin appends to `~/.local/share/opencode/log/todo-supervisor.log`
|
|
64
|
+
(prefixes `[todo-supervisor]` / `[todo-supervisor/tui]`). `console.log` inside
|
|
65
|
+
plugins is swallowed; use the log file.
|
|
66
|
+
|
|
67
|
+
## What it does
|
|
68
|
+
|
|
69
|
+
OpenCode has no built-in todo list, so the model often plans in prose and then
|
|
70
|
+
stops early, leaving half a task done. This plugin gives the model a durable list
|
|
71
|
+
and makes finishing it the path of least resistance:
|
|
72
|
+
|
|
73
|
+
1. the agent calls **`todo_write`** to lay out the work;
|
|
74
|
+
2. it works the list, calling `todo_write` as items move;
|
|
75
|
+
3. if it goes idle with items still open, the **supervisor** nudges it to
|
|
76
|
+
continue โ and if work *looks* running but has gone silent, it nudges about
|
|
77
|
+
that instead;
|
|
78
|
+
4. it is finished when no open items remain across all sources.
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
Four tools, all registered with `codemode: true` so Code Mode agents can call
|
|
83
|
+
them too. Together they are the whole interface the model uses.
|
|
84
|
+
|
|
85
|
+
### `todo_write`
|
|
86
|
+
|
|
87
|
+
Replaces the session's list.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"todos": [
|
|
92
|
+
{
|
|
93
|
+
"content": "string",
|
|
94
|
+
"status": "pending | in_progress | completed | canceled",
|
|
95
|
+
"priority": "high | medium | low"
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`content` and `status` are required; `priority` is optional.
|
|
102
|
+
|
|
103
|
+
- The list **replaces** the previous one โ always send the complete list.
|
|
104
|
+
- At most one item may be `in_progress`; extra ones are demoted to `pending` and
|
|
105
|
+
the result says so.
|
|
106
|
+
- Content is trimmed, empty entries dropped, duplicates removed.
|
|
107
|
+
- Returns the summary plus the canonical list, and resets the supervisor's
|
|
108
|
+
no-progress counter and any pause.
|
|
109
|
+
|
|
110
|
+
### `todo_read`
|
|
111
|
+
|
|
112
|
+
Input is `{}`. Returns the current list merged with subscribed markdown files
|
|
113
|
+
(`path:line` references), or `No todos.` when empty. The list is also injected
|
|
114
|
+
into context on every request, so this is only to double-check exact state.
|
|
115
|
+
|
|
116
|
+
### `todo_subscribe`
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{ "files": [{ "path": "string" }] }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Treats each file's GFM checklist (`- [ ]`) as todos for this session. Paths are
|
|
123
|
+
relative to the project directory (or absolute) and must end in `.md`/`.markdown`.
|
|
124
|
+
Returns the resulting subscription set.
|
|
125
|
+
|
|
126
|
+
### `todo_unsubscribe`
|
|
127
|
+
|
|
128
|
+
Same input as `todo_subscribe`. Stops supervising a file, and also shadows a
|
|
129
|
+
`markdownFiles` config default.
|
|
130
|
+
|
|
131
|
+
## Markdown mode
|
|
132
|
+
|
|
133
|
+
Subscribe files with `todo_subscribe`, the panel (`a` / `d`), or config. The
|
|
134
|
+
effective set is `config.markdownFiles โช stored files โ removed` per session.
|
|
135
|
+
|
|
136
|
+
- Conventions: `- [ ]` open, `- [x]`/`- [X]` completed, `- [~]` canceled. `-`,
|
|
137
|
+
`*`, `+`, numbered and indented items all count; fenced code blocks are ignored.
|
|
138
|
+
- Completion by the agent happens by **editing the file** (`- [x]`); nudge text
|
|
139
|
+
says so explicitly. The **panel can toggle a file item too** (`x`/`space` on a
|
|
140
|
+
markdown row): the plugin rewrites only that line's marker (indentation,
|
|
141
|
+
bullet, trailing text and CRLF endings preserved) and refuses paths that are
|
|
142
|
+
not subscribed.
|
|
143
|
+
- Item references are `path:line`, in both `todo_read` and nudges (grouped:
|
|
144
|
+
session list first, then one section per file).
|
|
145
|
+
- Missing files are valid subscriptions (0 tasks) and are picked up once created.
|
|
146
|
+
|
|
147
|
+
## TUI
|
|
148
|
+
|
|
149
|
+
- **Composer strip** (`session.composer.top`): `๐ โโโโโโโโ 8/17 open` (plus `โธ`
|
|
150
|
+
while paused), hidden when there is neither a list nor a subscription.
|
|
151
|
+
- **Popover** (`/todos`, palette "Todo: open todos"): a centered overlay dialog.
|
|
152
|
+
The body is height-bounded (up to 16 rows) so the key legend is always visible;
|
|
153
|
+
the header shows `open/total` and the selected `row n/m`. `f` cycles the dialog
|
|
154
|
+
size (medium โ large โ xlarge), `esc` closes it.
|
|
155
|
+
- header: progress bar, `open/total`, and a supervision chip (`idle ยท quiet
|
|
156
|
+
12s ยท nudges 2`, `working: 1 tool ยท 1 shell ยท quiet 4s`, `paused
|
|
157
|
+
(no-progress)`, `not supervised`);
|
|
158
|
+
- body: a `SESSION` section, then one collapsible section per subscribed file
|
|
159
|
+
(`n open of m`, `(missing)` badge, `:line` refs);
|
|
160
|
+
- keys: `โโ`/`jk` move, `pgup`/`pgdn`, `home`/`end`/`g`/`G`, `space`/`x` toggle
|
|
161
|
+
(session item, markdown item, or collapse a file row), `enter` collapse, `n`
|
|
162
|
+
nudge now, `s` pause/resume, `a` add file, `d` remove file, `r` refresh, `/`
|
|
163
|
+
filter, `f` resize, `c` clear (confirm), `?` help, `escape` close;
|
|
164
|
+
- it re-reads every 3 s while open, so edits made outside OpenCode show up
|
|
165
|
+
without waiting for an event.
|
|
166
|
+
- **Side panel** (palette "Todo: open side panel"): the same list docked to
|
|
167
|
+
`session.panel` (`todo.todos`), with `f` for fullscreen.
|
|
168
|
+
- `/todos add <path>` and `/todos remove <path>` manage subscriptions from the
|
|
169
|
+
prompt.
|
|
170
|
+
- **Toasts** on `nudged` (idle vs stalled wording) and on the all-done transition.
|
|
171
|
+
|
|
172
|
+
## Commands
|
|
173
|
+
|
|
174
|
+
| Where | Command | Effect |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| Slash | `/todos` | Open the todo popover for the current session |
|
|
177
|
+
| Slash | `/todos add <path>` | Subscribe a markdown file |
|
|
178
|
+
| Slash | `/todos remove <path>` | Unsubscribe a markdown file |
|
|
179
|
+
| Palette | `Todo: open todos` | Same as `/todos` |
|
|
180
|
+
| Palette | `Todo: open side panel` | Dock the list to `session.panel` instead of the popover |
|
|
181
|
+
|
|
182
|
+
## RPC
|
|
183
|
+
|
|
184
|
+
Contract id `todo` (`opencode-todo-supervisor/rpc`). The TUI uses
|
|
185
|
+
`context.client.rpc(TodoRpc)` and `rpc.events.on(...)`.
|
|
186
|
+
|
|
187
|
+
| Method | Input | Purpose |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `list` | `{ sessionID }` | Items, subscribed files (parsed fresh), and the supervisor snapshot |
|
|
190
|
+
| `clear` | `{ sessionID }` | Drop the stored list (subscriptions stay) |
|
|
191
|
+
| `nudge` | `{ sessionID }` | Send a nudge now (same dedupe/ownership rules as automatic nudges) |
|
|
192
|
+
| `setEnabled` | `{ sessionID, enabled }` | Per-session supervision kill switch |
|
|
193
|
+
| `toggle` | `{ sessionID, index }` | Flip one session item between completed and pending |
|
|
194
|
+
| `toggleFile` | `{ sessionID, path, line }` | Toggle a checklist item inside a subscribed markdown file |
|
|
195
|
+
| `subscribe` | `{ sessionID, path }` | Subscribe a markdown file |
|
|
196
|
+
| `unsubscribe` | `{ sessionID, path }` | Shadow a subscription |
|
|
197
|
+
|
|
198
|
+
Events: `updated` (`{ sessionID }`) and `nudged` (`{ sessionID, reason }`).
|
|
199
|
+
|
|
200
|
+
Over HTTP (service credentials required):
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
opencode2 api post /api/rpc/todo/list \
|
|
204
|
+
-H 'content-type:application/json' \
|
|
205
|
+
-d '{"input":{"sessionID":"ses_โฆ"}}'
|
|
206
|
+
# โ {"output": { items, files, supervisor }}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The body/response are wrapped in `{ "input": โฆ }` / `{ "output": โฆ }`.
|
|
210
|
+
|
|
211
|
+
## Options
|
|
212
|
+
|
|
213
|
+
| Option | Default | Meaning |
|
|
214
|
+
| --- | --- | --- |
|
|
215
|
+
| `enabled` | `true` | Master switch for supervision (tools keep working) |
|
|
216
|
+
| `settleMs` | `5000` | Debounce after a **confirmed end of turn** before the idle nudge |
|
|
217
|
+
| `minNudgeIntervalMs` | `45000` | Minimum gap between two nudges for one session |
|
|
218
|
+
| `stallMinutes` | `5` | With any work in flight: minutes of silence before a *stall* nudge |
|
|
219
|
+
| `maxNoProgressNudges` | `5` | Consecutive nudges with an unchanged open-item set before backing off |
|
|
220
|
+
| `injectTodoContext` | `true` | Append the current list to every model request (survives compaction) |
|
|
221
|
+
| `markdownFiles` | `[]` | Markdown checklists auto-subscribed for sessions in the project |
|
|
222
|
+
| `scope` | `"project"` | Only supervise sessions of the plugin's own project (`"project"` or `"all"`) |
|
|
223
|
+
| `pauseOnInterrupt` | `true` | Pause supervision after an interrupted execution until the user continues |
|
|
224
|
+
| `debugEvents` | `false` | Log the raw event stream |
|
|
225
|
+
|
|
226
|
+
## How it works
|
|
227
|
+
|
|
228
|
+
One server plugin instance runs per OpenCode location. It registers the todo
|
|
229
|
+
tools, subscribes to the session event stream to supervise activity, injects the
|
|
230
|
+
current list into each model request, and exposes an RPC contract that the TUI
|
|
231
|
+
reads.
|
|
232
|
+
|
|
233
|
+
### Event-driven supervision
|
|
234
|
+
|
|
235
|
+
The supervisor subscribes to the public event stream and keeps a per-session
|
|
236
|
+
state machine in memory (`src/supervisor.ts`). It does **not** trust a session
|
|
237
|
+
"status" flag โ this runtime does not reliably emit one. Instead it tracks
|
|
238
|
+
**activity spans**: anything the runtime is currently doing, opened by a
|
|
239
|
+
`*.started` (or `.called`) event and closed by its matching
|
|
240
|
+
`*.ended`/`.success`/`.failed`:
|
|
241
|
+
|
|
242
|
+
| Doing | Opened by | Closed by |
|
|
243
|
+
| --- | --- | --- |
|
|
244
|
+
| turn | `execution.started` | `execution.succeeded/failed/interrupted`, `status idle` |
|
|
245
|
+
| step | `step.started` | `step.ended` (with `finish`, see below) |
|
|
246
|
+
| thinking | `reasoning.started` | `reasoning.ended` |
|
|
247
|
+
| writing | `text.started` | `text.ended` |
|
|
248
|
+
| preparing a tool | `tool.input.started` | `tool.input.ended` |
|
|
249
|
+
| running a tool (`task`, `shell`, โฆ) | `tool.input.started`/`tool.called` | `tool.success`/`failed` |
|
|
250
|
+
| background shell | `shell.created` | `shell.exited`/`deleted` |
|
|
251
|
+
| queued input | `inbox.enqueued` | `inbox.delivered` |
|
|
252
|
+
|
|
253
|
+
A session is idle **only when nothing is open** โ no span, no in-flight tool
|
|
254
|
+
call, no background shell, no still-busy child, no queued inbox item. A step that
|
|
255
|
+
ends with `finish:"tool-calls"` means "the turn continues" and never arms a
|
|
256
|
+
nudge; a step ending with `finish:"stop"`, an execution `succeeded/failed/
|
|
257
|
+
interrupted`, or a `status idle` is a **confirmed end of turn** (`endTurn()`),
|
|
258
|
+
which also heals spans whose close event was lost (an interrupt or a reload
|
|
259
|
+
mid-call).
|
|
260
|
+
|
|
261
|
+
### `evaluate(sessionID)`
|
|
262
|
+
|
|
263
|
+
On every relevant event the supervisor (re)arms a per-session timer and
|
|
264
|
+
recomputes from current state:
|
|
265
|
+
|
|
266
|
+
1. if there is nothing open in any source, or supervision is paused/blocked โ
|
|
267
|
+
stop;
|
|
268
|
+
2. if work is in flight (spans, tools, shells, busy children), measure the quiet
|
|
269
|
+
time โ schedule a re-check if it is under `stallMinutes`, otherwise send a
|
|
270
|
+
**stall nudge** naming what is quiet;
|
|
271
|
+
3. if nothing is open, wait for the debounce โ `settleMs` after a confirmed turn
|
|
272
|
+
end, or the more conservative `stallMinutes` if no end-of-turn signal was ever
|
|
273
|
+
seen โ then send an **idle nudge**.
|
|
274
|
+
|
|
275
|
+
### Nudging
|
|
276
|
+
|
|
277
|
+
Nudges go out through `ctx.session.synthetic({ sessionID, text, description,
|
|
278
|
+
metadata })`, so the model receives them as ordinary instructions rather than a
|
|
279
|
+
visible user turn. Each nudge:
|
|
280
|
+
|
|
281
|
+
- carries the counts, the open items (with priorities), and an explainer โ e.g.
|
|
282
|
+
`Idle nudge #2 โ the session went idle with 3 of 7 todos left.` or
|
|
283
|
+
`Stall nudge #1 โ no activity for ~7.2 min from: subagent ses_โฆ / background
|
|
284
|
+
shell "sleep 200" / tool subagent "Add the panel" / open step (stall threshold
|
|
285
|
+
5 min).`;
|
|
286
|
+
- is rate-limited (`minNudgeIntervalMs`) and de-duplicated by an open-item hash;
|
|
287
|
+
- backs off after `maxNoProgressNudges` nudges with an unchanged list and resumes
|
|
288
|
+
on the next `todo_write` (the panel's `s` can pause/resume too).
|
|
289
|
+
|
|
290
|
+
**Output counts as progress, not just events.** A shell/task writes its stdout to
|
|
291
|
+
a capture file, and appending to it emits no plugin event at all โ so the
|
|
292
|
+
supervisor stats each tracked shell's capture (`shell.created.info.file`) and uses
|
|
293
|
+
its mtime as activity. A task that keeps printing is never "stalled", no matter
|
|
294
|
+
how few events it produces. The quiet clock is also **transitive**: a parent
|
|
295
|
+
includes the shells and tools of its still-busy children, and a child stops
|
|
296
|
+
counting once it has ended its turn.
|
|
297
|
+
|
|
298
|
+
### Context injection
|
|
299
|
+
|
|
300
|
+
On every model request (`ctx.session.hook("context")`) the plugin appends a
|
|
301
|
+
compact system block with the current list and a one-line policy. This is read
|
|
302
|
+
from an in-memory cache (kept fresh by tool writes and scans), so it adds no async
|
|
303
|
+
work per call โ and it **survives compaction**: even if the `todo_write` history
|
|
304
|
+
is summarized away, the list is re-injected on the next request.
|
|
305
|
+
|
|
306
|
+
### Storage
|
|
307
|
+
|
|
308
|
+
A thin wrapper over `ctx.storage` (durable JSON scoped to the plugin):
|
|
309
|
+
|
|
310
|
+
| Key | Value |
|
|
311
|
+
| --- | --- |
|
|
312
|
+
| `todos/<sessionID>` | `{ version, sessionID, items[], updatedAt }` |
|
|
313
|
+
| `subs/<sessionID>` | `{ version, sessionID, files[], removed[], updatedAt }` |
|
|
314
|
+
| `claims/<sessionID>` | `{ tag, at }` โ hot-reload ownership |
|
|
315
|
+
|
|
316
|
+
On startup the plugin scans `todos/` and reconciles against `ctx.session.get()`
|
|
317
|
+
(session gone โ drop the entry; session alive โ register and evaluate once).
|
|
318
|
+
Supervision counters (nudge counts, no-progress, pause) are in-memory only; a
|
|
319
|
+
restart resets them, which is deliberate and simpler. Sessions are dropped on
|
|
320
|
+
`session.deleted`.
|
|
321
|
+
|
|
322
|
+
### Hot reloads
|
|
323
|
+
|
|
324
|
+
The runtime may load a new plugin generation before disposing the old one. Every
|
|
325
|
+
registration writes an **ownership claim** (`claims/<sessionID> = { tag, at }`),
|
|
326
|
+
and a nudge is only sent when no strictly newer claim exists โ so exactly one
|
|
327
|
+
generation of the plugin nudge a given session, even while two are alive.
|
|
328
|
+
The event loop also stops as soon as its abort signal fires.
|
|
329
|
+
|
|
330
|
+
### Freshness
|
|
331
|
+
|
|
332
|
+
There is no filesystem watcher. Instead:
|
|
333
|
+
|
|
334
|
+
- `evaluate()`, RPC `list` and `todo_read` re-parse subscribed files fresh;
|
|
335
|
+
- a `ctx.tool.hook("execute.after")` re-parses immediately when an
|
|
336
|
+
`edit`/`write`/`patch` touches a subscribed file and emits RPC `updated`;
|
|
337
|
+
- the open panel polls every 3 s.
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
- Edit any file in this directory โ the running service hot-reloads the plugin
|
|
342
|
+
(source edits and config content changes; `touch` alone does not).
|
|
343
|
+
- `opencode2 reload` forces reconciliation; `opencode2 service restart` only as a
|
|
344
|
+
last resort (it interrupts sessions).
|
|
345
|
+
- Tests: `bun test` (106 tests across parser, storage, tools, RPC, state machine,
|
|
346
|
+
panel helpers).
|
|
347
|
+
- Watch logs: `tail -f ~/.local/share/opencode/log/todo-supervisor.log`.
|
|
348
|
+
|
|
349
|
+
## Limitations
|
|
350
|
+
|
|
351
|
+
- The TUI panel/keyboard actions and toasts need a running TUI; the strip counts
|
|
352
|
+
refresh on RPC `updated` (i.e. immediately after `todo_write`).
|
|
353
|
+
- Markdown write-back edits your files: `x` on a markdown row rewrites that
|
|
354
|
+
checkbox in place (temp file + atomic rename, only for subscribed paths). Use
|
|
355
|
+
the panel filter and `d` if you only want to read.
|
|
356
|
+
- `pauseOnInterrupt` relies on the execution-interrupted event: after an
|
|
357
|
+
interrupt, supervision resumes on the next `todo_write`/status event.
|
|
358
|
+
- No filesystem watcher: external edits are picked up at the next evaluation, the
|
|
359
|
+
next UI read, or the panel's 3 s poll (see Freshness).
|
|
360
|
+
- Subscription reads can briefly lag across roles right after a write;
|
|
361
|
+
supervision and the UI re-read per call and are unaffected.
|
|
362
|
+
- Runtimes that never emit an end-of-turn signal fall back to the `stallMinutes`
|
|
363
|
+
window for idle nudges (see `evaluate`).
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
MIT
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hacky.software/opencode-todo-supervisor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "OpenCode V2 plugin that gives every session a todo list (todo_write/todo_read), supervises idle sessions until the list is done, and can drive checklists in markdown files from a TUI panel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "n0k0",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"opencode",
|
|
10
|
+
"opencode-plugin",
|
|
11
|
+
"todo",
|
|
12
|
+
"todos",
|
|
13
|
+
"checklist",
|
|
14
|
+
"supervisor",
|
|
15
|
+
"tui",
|
|
16
|
+
"markdown"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/N0K0/opencode-todo-supervisor#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/N0K0/opencode-todo-supervisor.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/N0K0/opencode-todo-supervisor/issues"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.ts",
|
|
31
|
+
"./rpc": "./src/rpc.ts",
|
|
32
|
+
"./tui": "./src/tui.tsx"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"index.ts",
|
|
37
|
+
"tui.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "bun test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@opencode/plugin": "2.0.24"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@opentui/core": ">=0.5.14",
|
|
49
|
+
"@opentui/solid": ">=0.5.14",
|
|
50
|
+
"solid-js": ">=1.9.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@opentui/core": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@opentui/solid": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"solid-js": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// todo-supervisor โ OpenCode V2 server plugin.
|
|
2
|
+
//
|
|
3
|
+
// Aspect 1 (scaffold & loading) done: registers, logs setup/unload, options
|
|
4
|
+
// flow. Aspect 2 wired the raw event stream behind `debugEvents` for payload
|
|
5
|
+
// verification. Aspects 3โ5 added options parsing, storage and the todo tools.
|
|
6
|
+
// Aspect 6+ wires the event stream into the supervisor state machine
|
|
7
|
+
// (src/supervisor.ts, PLAN.md ยง5.4).
|
|
8
|
+
//
|
|
9
|
+
// Hot-reload note: the runtime may start a new plugin generation before/without
|
|
10
|
+
// disposing the old one. Every instance therefore gets a unique tag, the event
|
|
11
|
+
// loop stops as soon as its abort signal fires, cleanup is idempotent and the
|
|
12
|
+
// supervisor uses storage ownership claims to make duplicate nudges impossible.
|
|
13
|
+
import { basename } from "node:path"
|
|
14
|
+
import { Plugin } from "@opencode/plugin"
|
|
15
|
+
import { log } from "./log"
|
|
16
|
+
import { MarkdownCache } from "./markdown"
|
|
17
|
+
import { parseOptions } from "./options"
|
|
18
|
+
import { registerTodoRpc } from "./rpc"
|
|
19
|
+
import { makeStore } from "./storage"
|
|
20
|
+
import { normalizeEvent, Supervisor, truncate } from "./supervisor"
|
|
21
|
+
import { makeContextInjector, registerExecuteAfterHook, registerTools } from "./tools"
|
|
22
|
+
|
|
23
|
+
// Module-level probe: if this counter keeps growing across reloads, plugin
|
|
24
|
+
// generations share one module instance (a truth we want to know).
|
|
25
|
+
let moduleLoads = 0
|
|
26
|
+
let instanceSeq = 0
|
|
27
|
+
|
|
28
|
+
export default Plugin.define({
|
|
29
|
+
id: "todo-supervisor",
|
|
30
|
+
async setup(ctx) {
|
|
31
|
+
const options = parseOptions(ctx.options)
|
|
32
|
+
const tag = `${basename(ctx.location.directory)}#${++instanceSeq}@${Math.random().toString(36).slice(2, 6)}`
|
|
33
|
+
const instanceLog = (...args: unknown[]) => log(`(${tag})`, ...args)
|
|
34
|
+
|
|
35
|
+
instanceLog(`module load #${++moduleLoads}`)
|
|
36
|
+
instanceLog(`setup โ OpenCode ${ctx.app.version} ยท location=${ctx.location.directory} ยท project=${ctx.location.project.id}`)
|
|
37
|
+
instanceLog(`options: ${JSON.stringify(options)}`)
|
|
38
|
+
|
|
39
|
+
const store = makeStore(ctx.storage)
|
|
40
|
+
const markdown = new MarkdownCache()
|
|
41
|
+
// RPC events (aspect 11): the emitter is swapped in once the registration
|
|
42
|
+
// exists; until then (and if registration fails) it is a no-op.
|
|
43
|
+
let rpcEmit: (name: "updated" | "nudged", payload: Record<string, unknown>) => void = () => {}
|
|
44
|
+
const supervisor = new Supervisor({
|
|
45
|
+
options,
|
|
46
|
+
store,
|
|
47
|
+
projectID: ctx.location.project.id,
|
|
48
|
+
tag,
|
|
49
|
+
log: instanceLog,
|
|
50
|
+
emitRpcEvent: (name, payload) => rpcEmit(name, payload),
|
|
51
|
+
markdown,
|
|
52
|
+
directory: ctx.location.directory,
|
|
53
|
+
synthetic: async (input) => {
|
|
54
|
+
await ctx.session.synthetic(input)
|
|
55
|
+
},
|
|
56
|
+
getSession: async (sessionID) => {
|
|
57
|
+
const info = await ctx.session.get({ sessionID })
|
|
58
|
+
return { projectID: info.projectID, parentID: info.parentID }
|
|
59
|
+
},
|
|
60
|
+
claims: ctx.storage,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const tools = await registerTools(ctx, store, {
|
|
64
|
+
log: instanceLog,
|
|
65
|
+
onTodosChanged(sessionID) {
|
|
66
|
+
supervisor.notifyTodosChanged(sessionID)
|
|
67
|
+
rpcEmit("updated", { sessionID })
|
|
68
|
+
},
|
|
69
|
+
}, options, { markdown, directory: ctx.location.directory })
|
|
70
|
+
|
|
71
|
+
// Context injection (ยง5.5, aspect 10): a system block on every model request.
|
|
72
|
+
let contextRegistration: { dispose(): Promise<void> } | undefined
|
|
73
|
+
try {
|
|
74
|
+
const injector = makeContextInjector(store, options, instanceLog)
|
|
75
|
+
contextRegistration = await ctx.session.hook("context", (event) => injector(event))
|
|
76
|
+
instanceLog(`context hook registered (injectTodoContext=${options.injectTodoContext})`)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
instanceLog(`context hook registration failed: ${String(err)}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// RPC (ยง5.6, aspect 11): contract registration + event emitter.
|
|
82
|
+
let rpcRegistration: { dispose(): Promise<void> } | undefined
|
|
83
|
+
try {
|
|
84
|
+
const reg = await registerTodoRpc(ctx, {
|
|
85
|
+
store,
|
|
86
|
+
supervisor,
|
|
87
|
+
options,
|
|
88
|
+
markdown,
|
|
89
|
+
directory: ctx.location.directory,
|
|
90
|
+
log: instanceLog,
|
|
91
|
+
})
|
|
92
|
+
rpcRegistration = reg
|
|
93
|
+
rpcEmit = (name, payload) => reg.emit(name, payload)
|
|
94
|
+
instanceLog("rpc registered (todo.list/clear/nudge/setEnabled/toggle/toggleFile/subscribe/unsubscribe)")
|
|
95
|
+
} catch (err) {
|
|
96
|
+
instanceLog(`rpc registration failed: ${String(err)}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Markdown refresh on edits of subscribed files (ยง5.8, aspect 14c).
|
|
100
|
+
let toolHookRegistration: { dispose(): Promise<void> } | undefined
|
|
101
|
+
try {
|
|
102
|
+
toolHookRegistration = await registerExecuteAfterHook(ctx, {
|
|
103
|
+
store,
|
|
104
|
+
markdown,
|
|
105
|
+
options,
|
|
106
|
+
directory: ctx.location.directory,
|
|
107
|
+
onChanged: (sessionID) => {
|
|
108
|
+
supervisor.notifyTodosChanged(sessionID)
|
|
109
|
+
rpcEmit("updated", { sessionID })
|
|
110
|
+
},
|
|
111
|
+
log: instanceLog,
|
|
112
|
+
})
|
|
113
|
+
instanceLog("execute.after hook registered (markdown refresh)")
|
|
114
|
+
} catch (err) {
|
|
115
|
+
instanceLog(`execute.after hook registration failed: ${String(err)}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ac = new AbortController()
|
|
119
|
+
void runEventLoop(ctx, supervisor, instanceLog, { debugEvents: options.debugEvents }, ac.signal)
|
|
120
|
+
|
|
121
|
+
// Startup reconcile: adopt stored lists whose sessions still exist (ยง5.4).
|
|
122
|
+
void supervisor
|
|
123
|
+
.reconcile()
|
|
124
|
+
.catch((err) => instanceLog(`reconcile failed: ${String(err)}`))
|
|
125
|
+
|
|
126
|
+
let cleaned = false
|
|
127
|
+
return async () => {
|
|
128
|
+
if (cleaned) return
|
|
129
|
+
cleaned = true
|
|
130
|
+
instanceLog("cleanup โ disposing supervisor")
|
|
131
|
+
ac.abort()
|
|
132
|
+
supervisor.dispose()
|
|
133
|
+
await tools.dispose()
|
|
134
|
+
if (contextRegistration) {
|
|
135
|
+
try {
|
|
136
|
+
await contextRegistration.dispose()
|
|
137
|
+
} catch (err) {
|
|
138
|
+
instanceLog(`context hook dispose failed: ${String(err)}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (rpcRegistration) {
|
|
142
|
+
try {
|
|
143
|
+
await rpcRegistration.dispose()
|
|
144
|
+
} catch (err) {
|
|
145
|
+
instanceLog(`rpc dispose failed: ${String(err)}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (toolHookRegistration) {
|
|
149
|
+
try {
|
|
150
|
+
await toolHookRegistration.dispose()
|
|
151
|
+
} catch (err) {
|
|
152
|
+
instanceLog(`execute.after hook dispose failed: ${String(err)}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
instanceLog("unloaded")
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
async function runEventLoop(
|
|
161
|
+
ctx: Plugin.Context,
|
|
162
|
+
supervisor: Supervisor,
|
|
163
|
+
instanceLog: (...args: unknown[]) => void,
|
|
164
|
+
opts: { debugEvents: boolean },
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
instanceLog("event loop started")
|
|
168
|
+
try {
|
|
169
|
+
for await (const raw of ctx.event.subscribe({ signal })) {
|
|
170
|
+
if (signal.aborted) break
|
|
171
|
+
try {
|
|
172
|
+
if (opts.debugEvents) {
|
|
173
|
+
const event = normalizeEvent(raw)
|
|
174
|
+
if (event) instanceLog(`event ${event.type}`, truncate(JSON.stringify(event.data)))
|
|
175
|
+
}
|
|
176
|
+
// supervisor.handle swallows per-event errors by design (ยง11, Aspect 6).
|
|
177
|
+
await supervisor.handle(raw)
|
|
178
|
+
} catch (err) {
|
|
179
|
+
instanceLog("event handler error", String(err))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
instanceLog(`event loop ended (aborted=${signal.aborted})`)
|
|
183
|
+
} catch (err) {
|
|
184
|
+
instanceLog(`event loop failed (aborted=${signal.aborted})`, String(err))
|
|
185
|
+
}
|
|
186
|
+
}
|