@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 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
- # Temporary Holding Version
1
+ # todo-supervisor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,3 @@
1
+ // Root-level shim so this directory works as a discovery-style plugin package
2
+ // (see PLAN.md ยง7 fallback ladder, rung 3). The real implementation lives in src/.
3
+ export { default } from "./src/index"
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@hacky.software/opencode-todo-supervisor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }