opencode-session-todo 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -31,4 +31,4 @@ Reload OpenCode and reopen the TUI.
31
31
  - **Keep tasks in view** in the sidebar; open `/todos` for the full list.
32
32
  - **Own the file** at `.agents/sessions/<session-id>/todo.txt`.
33
33
 
34
- [Usage & permissions](USAGE.md) · [Development & publishing](docs/development.md) · [MIT](LICENSE)
34
+ [MIT](LICENSE)
package/dist/tui.js CHANGED
@@ -115,6 +115,11 @@ var colorFor = (status, theme) => {
115
115
  return theme.text.feedback.error.base;
116
116
  }
117
117
  };
118
+ var priorityBadge = {
119
+ high: "(A)",
120
+ medium: "(B)",
121
+ low: "(C)"
122
+ };
118
123
  var Message = Schema4.Struct({
119
124
  message: Schema4.String
120
125
  });
@@ -137,16 +142,6 @@ var tui_default = Plugin.define({
137
142
  open: true
138
143
  }
139
144
  });
140
- const notify = api.events.on("updated", (event) => {
141
- if (event.data.summary === "")
142
- return;
143
- ctx.ui.toast.show({
144
- title: "TODO updated",
145
- message: event.data.summary,
146
- variant: "info",
147
- sessionID: event.data.sessionID
148
- });
149
- });
150
145
  const List = (props) => {
151
146
  const [view, setView] = createSignal({
152
147
  status: "loading"
@@ -225,127 +220,215 @@ var tui_default = Plugin.define({
225
220
  return current.status === "error" ? current.message : undefined;
226
221
  };
227
222
  const visible = () => Boolean(props.full || snapshot()?.todos.length || error());
228
- return _$createComponent(Show, {
223
+ const Header = () => (() => {
224
+ var _el$ = _$createElement("box"), _el$3 = _$createElement("box"), _el$4 = _$createElement("text"), _el$5 = _$createElement("b"), _el$7 = _$createElement("span"), _el$8 = _$createTextNode(` (`), _el$0 = _$createTextNode(` remaining)`);
225
+ _$insertNode(_el$, _el$3);
226
+ _$setProp(_el$, "flexDirection", "row");
227
+ _$setProp(_el$, "gap", 1);
228
+ _$setProp(_el$, "flexShrink", 0);
229
+ _$setProp(_el$, "onMouseDown", (event) => {
230
+ if (props.full)
231
+ return;
232
+ event.stopPropagation();
233
+ updateSidebarView((draft) => {
234
+ draft.open = !draft.open;
235
+ }).catch((error) => {
236
+ ctx.ui.toast.show({
237
+ title: "Todo sidebar",
238
+ message: `Cannot save expanded state: ${messageFor(error)}`,
239
+ variant: "error"
240
+ });
241
+ });
242
+ });
243
+ _$insert(_el$, _$createComponent(Show, {
244
+ get when() {
245
+ return !props.full;
246
+ },
247
+ get children() {
248
+ var _el$2 = _$createElement("text");
249
+ _$insert(_el$2, () => sidebarView.open ? "\u25BC" : "\u25B6");
250
+ _$effect((_$p) => _$setProp(_el$2, "fg", ctx.theme.text.base, _$p));
251
+ return _el$2;
252
+ }
253
+ }), _el$3);
254
+ _$insertNode(_el$3, _el$4);
255
+ _$setProp(_el$3, "flexGrow", 1);
256
+ _$setProp(_el$3, "flexShrink", 1);
257
+ _$setProp(_el$3, "minWidth", 0);
258
+ _$insertNode(_el$4, _el$5);
259
+ _$insertNode(_el$4, _el$7);
260
+ _$insertNode(_el$5, _$createTextNode(`TODO`));
261
+ _$insertNode(_el$7, _el$8);
262
+ _$insertNode(_el$7, _el$0);
263
+ _$insert(_el$7, active, _el$0);
264
+ _$insert(_el$, _$createComponent(Show, {
265
+ get when() {
266
+ return _$memo(() => !!props.full)() ? props.onClose : undefined;
267
+ },
268
+ children: (onClose) => (() => {
269
+ var _el$1 = _$createElement("text");
270
+ _$insertNode(_el$1, _$createTextNode(`\xD7`));
271
+ _$setProp(_el$1, "flexShrink", 0);
272
+ _$setProp(_el$1, "selectable", false);
273
+ _$setProp(_el$1, "onMouseDown", (event) => {
274
+ event.stopPropagation();
275
+ onClose()();
276
+ });
277
+ _$effect((_$p) => _$setProp(_el$1, "fg", ctx.theme.text.muted, _$p));
278
+ return _el$1;
279
+ })()
280
+ }), null);
281
+ _$effect((_p$) => {
282
+ var _v$ = props.full ? 1 : 0, _v$2 = ctx.theme.text.base, _v$3 = {
283
+ fg: ctx.theme.text.muted
284
+ };
285
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "paddingBottom", _v$, _p$.e));
286
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
287
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$7, "style", _v$3, _p$.a));
288
+ return _p$;
289
+ }, {
290
+ e: undefined,
291
+ t: undefined,
292
+ a: undefined
293
+ });
294
+ return _el$;
295
+ })();
296
+ const Body = () => [_$createComponent(Show, {
229
297
  get when() {
230
- return visible();
298
+ return error();
231
299
  },
232
300
  get children() {
233
- var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$4 = _$createElement("text"), _el$5 = _$createElement("b"), _el$7 = _$createElement("span"), _el$8 = _$createTextNode(` (`), _el$0 = _$createTextNode(` remaining)`);
234
- _$insertNode(_el$, _el$2);
235
- _$setProp(_el$, "flexDirection", "column");
236
- _$setProp(_el$, "gap", 0);
237
- _$setProp(_el$, "paddingTop", 1);
238
- _$insertNode(_el$2, _el$4);
239
- _$setProp(_el$2, "flexDirection", "row");
240
- _$setProp(_el$2, "gap", 1);
241
- _$setProp(_el$2, "onMouseDown", (event) => {
242
- if (props.full)
243
- return;
244
- event.stopPropagation();
245
- updateSidebarView((draft) => {
246
- draft.open = !draft.open;
247
- }).catch((error) => {
248
- ctx.ui.toast.show({
249
- title: "Todo sidebar",
250
- message: `Cannot save expanded state: ${messageFor(error)}`,
251
- variant: "error"
301
+ var _el$11 = _$createElement("text"), _el$12 = _$createTextNode(`Cannot read todos: `);
302
+ _$insertNode(_el$11, _el$12);
303
+ _$insert(_el$11, error, null);
304
+ _$effect((_$p) => _$setProp(_el$11, "fg", ctx.theme.text.feedback.error.base, _$p));
305
+ return _el$11;
306
+ }
307
+ }), _$createComponent(Show, {
308
+ get when() {
309
+ return _$memo(() => !!(props.full && view().status === "ready"))() && todos().length === 0;
310
+ },
311
+ get children() {
312
+ var _el$13 = _$createElement("text");
313
+ _$insertNode(_el$13, _$createTextNode(`No todos for this session.`));
314
+ _$effect((_$p) => _$setProp(_el$13, "fg", ctx.theme.text.muted, _$p));
315
+ return _el$13;
316
+ }
317
+ }), _$createComponent(Show, {
318
+ get when() {
319
+ return props.full;
320
+ },
321
+ get fallback() {
322
+ return _$createComponent(For, {
323
+ get each() {
324
+ return todos();
325
+ },
326
+ children: (todo) => (() => {
327
+ var _el$18 = _$createElement("text");
328
+ _$insert(_el$18, () => formatTodo(todo));
329
+ _$effect((_$p) => _$setProp(_el$18, "fg", colorFor(todo.status, ctx.theme), _$p));
330
+ return _el$18;
331
+ })()
332
+ });
333
+ },
334
+ get children() {
335
+ return _$createComponent(For, {
336
+ get each() {
337
+ return todos();
338
+ },
339
+ children: (todo) => (() => {
340
+ var _el$19 = _$createElement("box"), _el$20 = _$createElement("text"), _el$21 = _$createElement("box"), _el$22 = _$createElement("text"), _el$23 = _$createElement("text");
341
+ _$insertNode(_el$19, _el$20);
342
+ _$insertNode(_el$19, _el$21);
343
+ _$insertNode(_el$19, _el$23);
344
+ _$setProp(_el$19, "flexDirection", "row");
345
+ _$setProp(_el$19, "gap", 1);
346
+ _$setProp(_el$20, "width", 3);
347
+ _$setProp(_el$20, "flexShrink", 0);
348
+ _$insert(_el$20, () => todoSymbols[todo.status]);
349
+ _$insertNode(_el$21, _el$22);
350
+ _$setProp(_el$21, "flexGrow", 1);
351
+ _$setProp(_el$21, "flexShrink", 1);
352
+ _$setProp(_el$21, "minWidth", 0);
353
+ _$setProp(_el$22, "wrapMode", "word");
354
+ _$insert(_el$22, () => todo.content);
355
+ _$setProp(_el$23, "width", 3);
356
+ _$setProp(_el$23, "flexShrink", 0);
357
+ _$insert(_el$23, () => priorityBadge[todo.priority]);
358
+ _$effect((_p$) => {
359
+ var _v$4 = colorFor(todo.status, ctx.theme), _v$5 = colorFor(todo.status, ctx.theme), _v$6 = ctx.theme.text.muted;
360
+ _v$4 !== _p$.e && (_p$.e = _$setProp(_el$20, "fg", _v$4, _p$.e));
361
+ _v$5 !== _p$.t && (_p$.t = _$setProp(_el$22, "fg", _v$5, _p$.t));
362
+ _v$6 !== _p$.a && (_p$.a = _$setProp(_el$23, "fg", _v$6, _p$.a));
363
+ return _p$;
364
+ }, {
365
+ e: undefined,
366
+ t: undefined,
367
+ a: undefined
252
368
  });
253
- });
369
+ return _el$19;
370
+ })()
371
+ });
372
+ }
373
+ }), _$createComponent(Show, {
374
+ get when() {
375
+ return more();
376
+ },
377
+ get children() {
378
+ var _el$15 = _$createElement("text"), _el$16 = _$createTextNode(`Show all `), _el$17 = _$createTextNode(` todos \u2192`);
379
+ _$insertNode(_el$15, _el$16);
380
+ _$insertNode(_el$15, _el$17);
381
+ _$setProp(_el$15, "onMouseDown", (event) => {
382
+ event.stopPropagation();
383
+ ctx.ui.panel.open("session-todo.list");
254
384
  });
255
- _$insert(_el$2, _$createComponent(Show, {
385
+ _$insert(_el$15, more, _el$17);
386
+ _$effect((_$p) => _$setProp(_el$15, "fg", ctx.theme.text.feedback.info.base, _$p));
387
+ return _el$15;
388
+ }
389
+ })];
390
+ return _$createComponent(Show, {
391
+ get when() {
392
+ return visible();
393
+ },
394
+ get children() {
395
+ return _$createComponent(Show, {
256
396
  get when() {
257
- return !props.full;
397
+ return props.full;
258
398
  },
259
- get children() {
260
- var _el$3 = _$createElement("text");
261
- _$insert(_el$3, () => sidebarView.open ? "\u25BC" : "\u25B6");
262
- _$effect((_$p) => _$setProp(_el$3, "fg", ctx.theme.text.base, _$p));
263
- return _el$3;
264
- }
265
- }), _el$4);
266
- _$insertNode(_el$4, _el$5);
267
- _$insertNode(_el$4, _el$7);
268
- _$insertNode(_el$5, _$createTextNode(`TODO`));
269
- _$insertNode(_el$7, _el$8);
270
- _$insertNode(_el$7, _el$0);
271
- _$insert(_el$7, active, _el$0);
272
- _$insert(_el$, _$createComponent(Show, {
273
- get when() {
274
- return props.full || sidebarView.open;
399
+ get fallback() {
400
+ return (() => {
401
+ var _el$26 = _$createElement("box");
402
+ _$setProp(_el$26, "flexDirection", "column");
403
+ _$setProp(_el$26, "gap", 0);
404
+ _$setProp(_el$26, "paddingTop", 1);
405
+ _$insert(_el$26, _$createComponent(Header, {}), null);
406
+ _$insert(_el$26, _$createComponent(Show, {
407
+ get when() {
408
+ return sidebarView.open;
409
+ },
410
+ get children() {
411
+ return _$createComponent(Body, {});
412
+ }
413
+ }), null);
414
+ return _el$26;
415
+ })();
275
416
  },
276
417
  get children() {
277
- return [_$createComponent(Show, {
278
- get when() {
279
- return props.full;
280
- },
281
- get children() {
282
- var _el$1 = _$createElement("text");
283
- _$insert(_el$1, () => snapshot()?.path ?? "Loading todo.txt\u2026");
284
- _$effect((_$p) => _$setProp(_el$1, "fg", ctx.theme.text.muted, _$p));
285
- return _el$1;
286
- }
287
- }), _$createComponent(Show, {
288
- get when() {
289
- return error();
290
- },
291
- get children() {
292
- var _el$10 = _$createElement("text"), _el$11 = _$createTextNode(`Cannot read todos: `);
293
- _$insertNode(_el$10, _el$11);
294
- _$insert(_el$10, error, null);
295
- _$effect((_$p) => _$setProp(_el$10, "fg", ctx.theme.text.feedback.error.base, _$p));
296
- return _el$10;
297
- }
298
- }), _$createComponent(Show, {
299
- get when() {
300
- return _$memo(() => !!(props.full && view().status === "ready"))() && todos().length === 0;
301
- },
302
- get children() {
303
- var _el$12 = _$createElement("text");
304
- _$insertNode(_el$12, _$createTextNode(`No todos for this session.`));
305
- _$effect((_$p) => _$setProp(_el$12, "fg", ctx.theme.text.muted, _$p));
306
- return _el$12;
307
- }
308
- }), _$createComponent(For, {
309
- get each() {
310
- return todos();
311
- },
312
- children: (todo) => (() => {
313
- var _el$17 = _$createElement("text");
314
- _$insert(_el$17, () => formatTodo(todo));
315
- _$effect((_$p) => _$setProp(_el$17, "fg", colorFor(todo.status, ctx.theme), _$p));
316
- return _el$17;
317
- })()
318
- }), _$createComponent(Show, {
319
- get when() {
320
- return more();
321
- },
322
- get children() {
323
- var _el$14 = _$createElement("text"), _el$15 = _$createTextNode(`Show all `), _el$16 = _$createTextNode(` todos \u2192`);
324
- _$insertNode(_el$14, _el$15);
325
- _$insertNode(_el$14, _el$16);
326
- _$setProp(_el$14, "onMouseDown", (event) => {
327
- event.stopPropagation();
328
- ctx.ui.panel.open("session-todo.list");
329
- });
330
- _$insert(_el$14, more, _el$16);
331
- _$effect((_$p) => _$setProp(_el$14, "fg", ctx.theme.text.feedback.info.base, _$p));
332
- return _el$14;
333
- }
334
- })];
418
+ var _el$24 = _$createElement("box"), _el$25 = _$createElement("scrollbox");
419
+ _$insertNode(_el$24, _el$25);
420
+ _$setProp(_el$24, "flexDirection", "column");
421
+ _$setProp(_el$24, "flexGrow", 1);
422
+ _$setProp(_el$24, "minHeight", 0);
423
+ _$setProp(_el$24, "padding", 1);
424
+ _$insert(_el$24, _$createComponent(Header, {}), _el$25);
425
+ _$setProp(_el$25, "flexGrow", 1);
426
+ _$setProp(_el$25, "flexBasis", 0);
427
+ _$setProp(_el$25, "minHeight", 0);
428
+ _$insert(_el$25, _$createComponent(Body, {}));
429
+ return _el$24;
335
430
  }
336
- }), null);
337
- _$effect((_p$) => {
338
- var _v$ = ctx.theme.text.base, _v$2 = {
339
- fg: ctx.theme.text.muted
340
- };
341
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "fg", _v$, _p$.e));
342
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "style", _v$2, _p$.t));
343
- return _p$;
344
- }, {
345
- e: undefined,
346
- t: undefined
347
431
  });
348
- return _el$;
349
432
  }
350
433
  });
351
434
  };
@@ -367,28 +450,15 @@ var tui_default = Plugin.define({
367
450
  run: props.close
368
451
  }]
369
452
  }));
370
- return (() => {
371
- var _el$18 = _$createElement("box"), _el$19 = _$createElement("text"), _el$21 = _$createElement("scrollbox");
372
- _$insertNode(_el$18, _el$19);
373
- _$insertNode(_el$18, _el$21);
374
- _$setProp(_el$18, "flexDirection", "column");
375
- _$setProp(_el$18, "flexGrow", 1);
376
- _$setProp(_el$18, "padding", 1);
377
- _$insertNode(_el$19, _$createTextNode(`Close \xD7 \xB7 Esc when focused \xB7 Ctrl+P to toggle`));
378
- _$setProp(_el$19, "onMouseDown", (event) => {
379
- event.stopPropagation();
380
- props.close();
381
- });
382
- _$setProp(_el$21, "flexGrow", 1);
383
- _$insert(_el$21, _$createComponent(List, {
384
- get sessionID() {
385
- return props.sessionID;
386
- },
387
- full: true
388
- }));
389
- _$effect((_$p) => _$setProp(_el$19, "fg", ctx.theme.text.base, _$p));
390
- return _el$18;
391
- })();
453
+ return _$createComponent(List, {
454
+ get sessionID() {
455
+ return props.sessionID;
456
+ },
457
+ full: true,
458
+ get onClose() {
459
+ return props.close;
460
+ }
461
+ });
392
462
  };
393
463
  const panel = ctx.ui.slot({
394
464
  append: "session.panel",
@@ -423,7 +493,6 @@ var tui_default = Plugin.define({
423
493
  return () => {
424
494
  sidebar();
425
495
  panel();
426
- notify();
427
496
  };
428
497
  }
429
498
  });
package/index.ts CHANGED
@@ -13,7 +13,10 @@ import { diffTodos } from "./src/diff.ts"
13
13
  import {
14
14
  TodoInstructions,
15
15
  TodoLifecycleInstructions,
16
+ TodoNamespaceDescription,
17
+ TodoReadAliasDescription,
16
18
  TodoReadInstructions,
19
+ TodoWriteAliasDescription,
17
20
  TodoWriteInstructions,
18
21
  } from "./src/instructions.ts"
19
22
  import { Snapshot, TodoError, Todos } from "./src/model.ts"
@@ -107,6 +110,16 @@ export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Sco
107
110
  return session
108
111
  })
109
112
 
113
+ const requireRoot = (session: Session.Info): Effect.Effect<Session.Info, Tool.Error> =>
114
+ session.parentID === undefined
115
+ ? Effect.succeed(session)
116
+ : Effect.fail(
117
+ new Tool.Error({
118
+ message:
119
+ "TODO tools are only available in root sessions. Subagent sessions cannot read or update the session's TODO list.",
120
+ }),
121
+ )
122
+
110
123
  const directoryFor = (session: Session.Info): Effect.Effect<SessionDirectory, TodoError> =>
111
124
  Schema.decodeUnknownEffect(SessionDirectory)({
112
125
  directory: session.location.directory,
@@ -135,6 +148,7 @@ export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Sco
135
148
  )(function*(input, context): Effect.fn.Return<Tool.Result<typeof Snapshot>, Tool.Error> {
136
149
  const session: Session.Info = yield* getSession(context.sessionID).pipe(
137
150
  Effect.mapError(toolError),
151
+ Effect.flatMap(requireRoot),
138
152
  )
139
153
  const agent: Agent.Info = (yield* ctx.agent
140
154
  .get({ agentID: context.agent })
@@ -186,19 +200,26 @@ export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Sco
186
200
  const executeRead: Tool.Info<typeof ReadInput, typeof Snapshot>["execute"] = (
187
201
  _input,
188
202
  context,
189
- ): Effect.Effect<Tool.Result<typeof Snapshot>, Tool.Error> => {
190
- return read(context.sessionID).pipe(
191
- Effect.map(
192
- (snapshot): Tool.Result<typeof Snapshot> => ({
193
- output: snapshot,
194
- content: JSON.stringify(snapshot),
195
- }),
196
- ),
197
- Effect.mapError(toolError),
198
- )
199
- }
203
+ ): Effect.Effect<Tool.Result<typeof Snapshot>, Tool.Error> =>
204
+ Effect.gen(function*(): Effect.fn.Return<Tool.Result<typeof Snapshot>, Tool.Error> {
205
+ const session: Session.Info = yield* getSession(context.sessionID).pipe(
206
+ Effect.mapError(toolError),
207
+ Effect.flatMap(requireRoot),
208
+ )
209
+ return yield* directoryFor(session).pipe(
210
+ Effect.flatMap(store.read),
211
+ Effect.map(
212
+ (snapshot): Tool.Result<typeof Snapshot> => ({
213
+ output: snapshot,
214
+ content: JSON.stringify(snapshot),
215
+ }),
216
+ ),
217
+ Effect.mapError(toolError),
218
+ )
219
+ })
200
220
 
201
221
  yield* ctx.tool.transform((editor): void => {
222
+ editor.namespace({ name: "todo", description: TodoNamespaceDescription })
202
223
  editor.add({
203
224
  name: "todowrite",
204
225
  description: `${TodoWriteInstructions}\n\n${TodoLifecycleInstructions}`,
@@ -215,6 +236,22 @@ export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Sco
215
236
  options: { codemode: false, permission: "todowrite" },
216
237
  execute: executeRead,
217
238
  })
239
+ editor.add({
240
+ name: "write",
241
+ description: TodoWriteAliasDescription,
242
+ input: WriteInput,
243
+ output: Snapshot,
244
+ options: { namespace: "todo", codemode: true, pinned: true, permission: "todowrite" },
245
+ execute: executeWrite,
246
+ })
247
+ editor.add({
248
+ name: "read",
249
+ description: TodoReadAliasDescription,
250
+ input: ReadInput,
251
+ output: Snapshot,
252
+ options: { namespace: "todo", codemode: true, pinned: true, permission: "todowrite" },
253
+ execute: executeRead,
254
+ })
218
255
  })
219
256
  }).pipe(Effect.orDie)
220
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-todo",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Session-local todo.txt tools and TUI for OpenCode V2",
5
5
  "homepage": "https://github.com/alexandru/opencode-todo#readme",
6
6
  "bugs": {
@@ -23,10 +23,8 @@
23
23
  "dist",
24
24
  "rpc.ts",
25
25
  "src",
26
- "USAGE.md",
27
26
  "LICENSE",
28
- "README.md",
29
- "docs/development.md"
27
+ "README.md"
30
28
  ],
31
29
  "scripts": {
32
30
  "build": "bun run build.ts",
@@ -4,7 +4,7 @@ Replace this session’s TODO list.
4
4
 
5
5
  - You MUST pass the last known saved list as \`previous\`; use \`[]\` when known empty.
6
6
  - You SHOULD reuse the returned saved list for subsequent writes.
7
- - On conflict, you MUST call \`todoread\` and retry.`
7
+ - On conflict, you MUST refresh the saved list and retry.`
8
8
 
9
9
  export const TodoReadInstructions: string = `### \`todoread\`
10
10
 
@@ -14,6 +14,12 @@ Read this session’s saved TODO list and storage path.
14
14
 
15
15
  export const TodoLifecycleInstructions: string = `### TODO lifecycle
16
16
 
17
+ - You MUST create a TODO list when you have more than one work item to track.
18
+ - You MUST NOT create a TODO list for a straightforward question or a single work item.
19
+ - You MUST NOT split a single work item into artificial steps just to justify a TODO list.
20
+
21
+ The following rules apply only when a TODO list is needed or already tracks unfinished work.
22
+
17
23
  - You MUST keep this session's TODO list synchronized with the requested work and its actual progress.
18
24
  - You MUST preserve existing tasks, order, statuses, and priorities unless actual progress or the user's instructions require a change.
19
25
  - You MUST mark a task in_progress before starting it. You MUST keep at most one task in_progress.
@@ -23,5 +29,30 @@ export const TodoLifecycleInstructions: string = `### TODO lifecycle
23
29
  - Before your final response, you MUST reconcile the list with what was actually done and accurately retain unfinished work.
24
30
  - You SHOULD keep task descriptions concise and actionable.`
25
31
 
32
+ export const TodoCodeModeInstructions: string = `### Code Mode
33
+
34
+ - You SHOULD prefer Code Mode for TODO updates.
35
+ - When the current list is known, call \`tools.todo.write\` directly with that \`previous\` list; do not read first.
36
+ - Read with \`tools.todo.read()\` only when the saved list is unknown or a write reports a conflict.
37
+ - When you need both, perform them sequentially in one execute call: await the read, pass the returned \`todos\` as \`previous\`, await the write, and return only the final snapshot.
38
+ - You MUST NOT use \`Promise.all\` for dependent TODO operations.
39
+ - You MUST NOT batch lifecycle transitions in one call.
40
+
41
+ \`\`\`ts
42
+ const { todos } = await tools.todo.read()
43
+ return await tools.todo.write({
44
+ previous: todos,
45
+ todos: [...todos, { content: "Run the tests", status: "pending", priority: "high" }],
46
+ })
47
+ \`\`\``
48
+
49
+ export const TodoWriteAliasDescription: string = "Replace this session’s TODO list."
50
+
51
+ export const TodoReadAliasDescription: string =
52
+ "Read this session’s saved TODO list and storage path."
53
+
54
+ export const TodoNamespaceDescription: string =
55
+ "Session TODO list tools. Prefer one execute call per update: write with the last known list; read only when the saved list is unknown or a write reports a conflict."
56
+
26
57
  export const TodoInstructions: string =
27
- `${TodoWriteInstructions}\n\n${TodoReadInstructions}\n\n${TodoLifecycleInstructions}`
58
+ `${TodoWriteInstructions}\n\n${TodoReadInstructions}\n\n${TodoLifecycleInstructions}\n\n${TodoCodeModeInstructions}`
package/src/store.ts CHANGED
@@ -65,21 +65,31 @@ const requireDirectory = async (directory: string): Promise<void> => {
65
65
  }
66
66
  }
67
67
 
68
- const load = Effect.fnUntraced(function*(path: string): Effect.fn.Return<Document, TodoError> {
69
- const text: string = yield* io(`Cannot read ${path}`, async (): Promise<string> => {
70
- try {
71
- const info: Stats = await lstat(path)
72
- if (!info.isFile()) {
73
- throw new Error("todo.txt must be a regular file")
74
- }
75
- return await readFile(path, "utf8")
76
- } catch (error) {
77
- if (missing(error)) return ""
78
- throw error
79
- }
80
- })
81
- return yield* parse(text)
82
- })
68
+ interface LoadedDocument extends Document {
69
+ readonly exists: boolean
70
+ }
71
+
72
+ const load = Effect.fnUntraced(
73
+ function*(path: string): Effect.fn.Return<LoadedDocument, TodoError> {
74
+ const file: { readonly text: string; readonly exists: boolean } = yield* io(
75
+ `Cannot read ${path}`,
76
+ async (): Promise<{ readonly text: string; readonly exists: boolean }> => {
77
+ try {
78
+ const info: Stats = await lstat(path)
79
+ if (!info.isFile()) {
80
+ throw new Error("todo.txt must be a regular file")
81
+ }
82
+ return { text: await readFile(path, "utf8"), exists: true }
83
+ } catch (error) {
84
+ if (missing(error)) return { text: "", exists: false }
85
+ throw error
86
+ }
87
+ },
88
+ )
89
+ const document: Document = yield* parse(file.text)
90
+ return { ...document, exists: file.exists }
91
+ },
92
+ )
83
93
 
84
94
  const checkDirectory = Effect.fnUntraced(function*(
85
95
  session: SessionDirectory,
@@ -132,13 +142,16 @@ export const makeTodoStore: Effect.Effect<TodoStore> = Effect.gen(
132
142
  Effect.gen(function*(): Effect.fn.Return<WriteResult, TodoError> {
133
143
  const path: string = pathFor(session)
134
144
  yield* ensureDirectory(session)
135
- const previous: Document = yield* load(path)
145
+ const previous: LoadedDocument = yield* load(path)
136
146
  if (expected !== undefined && !sameTodos(expected, previous.todos)) {
137
147
  return yield* new TodoError({
138
148
  message:
139
- "The stored todo list does not match the `previous` list you sent. Use todoread, then retry todowrite with the refreshed `previous` list.",
149
+ "The stored todo list does not match the `previous` list you sent. Use todoread or tools.todo.read(), then retry todowrite with the refreshed `previous` list.",
140
150
  })
141
151
  }
152
+ if (previous.exists && sameTodos(todos, previous.todos)) {
153
+ return { path, todos, previous: previous.todos }
154
+ }
142
155
  const temporary: string = `${path}.${randomUUID()}.tmp`
143
156
  const today: string = DateTime.formatIsoDateUtc(yield* DateTime.now)
144
157
  yield* io(`Cannot write ${path}`, async (): Promise<void> => {
@@ -149,8 +162,9 @@ export const makeTodoStore: Effect.Effect<TodoStore> = Effect.gen(
149
162
  flag: "wx",
150
163
  })
151
164
  await rename(temporary, path)
152
- } finally {
165
+ } catch (error) {
153
166
  await rm(temporary, { force: true })
167
+ throw error
154
168
  }
155
169
  })
156
170
  return { path, todos, previous: previous.todos }
package/tui.tsx CHANGED
@@ -17,17 +17,16 @@ import {
17
17
  import { TodoRpc } from "./rpc.ts"
18
18
  import { type Snapshot, type Todo } from "./src/model.ts"
19
19
  import { projectTodos, SidebarOptions, type TodoProjection } from "./src/sidebar.ts"
20
- import { formatTodo } from "./src/summary.ts"
20
+ import { formatTodo, todoSymbols } from "./src/summary.ts"
21
21
 
22
22
  type View =
23
23
  | { readonly status: "loading" }
24
24
  | { readonly status: "ready"; readonly snapshot: Snapshot }
25
25
  | { readonly status: "error"; readonly message: string }
26
26
 
27
- interface Props {
28
- readonly sessionID: string
29
- readonly full?: boolean
30
- }
27
+ type Props =
28
+ | { readonly sessionID: string; readonly full: true; readonly onClose: () => void }
29
+ | { readonly sessionID: string; readonly full?: false; readonly onClose?: never }
31
30
 
32
31
  const colorFor = (
33
32
  status: Todo["status"],
@@ -45,6 +44,12 @@ const colorFor = (
45
44
  }
46
45
  }
47
46
 
47
+ const priorityBadge: Readonly<Record<Todo["priority"], string>> = {
48
+ high: "(A)",
49
+ medium: "(B)",
50
+ low: "(C)",
51
+ }
52
+
48
53
  const Message = Schema.Struct({ message: Schema.String })
49
54
  const messageFor = (error: unknown): string =>
50
55
  Schema.is(Message)(error) ? error.message : String(error)
@@ -65,15 +70,6 @@ export default Plugin.define({
65
70
  const [sidebarView, updateSidebarView] = ctx.storage.store("sidebar", {
66
71
  initial: { open: true },
67
72
  })
68
- const notify: () => void = api.events.on("updated", (event): void => {
69
- if (event.data.summary === "") return
70
- ctx.ui.toast.show({
71
- title: "TODO updated",
72
- message: event.data.summary,
73
- variant: "info",
74
- sessionID: event.data.sessionID,
75
- })
76
- })
77
73
  const List = (props: Props): JSX.Element => {
78
74
  const [view, setView] = createSignal<View>({ status: "loading" })
79
75
  createEffect((): void => {
@@ -160,64 +156,125 @@ export default Plugin.define({
160
156
  }
161
157
  const visible: Accessor<boolean> = (): boolean =>
162
158
  Boolean(props.full || snapshot()?.todos.length || error())
163
- return (
164
- <Show when={visible()}>
165
- <box flexDirection="column" gap={0} paddingTop={1}>
166
- <box
167
- flexDirection="row"
168
- gap={1}
169
- onMouseDown={(event: MouseEvent): void => {
170
- if (props.full) return
171
- event.stopPropagation()
172
- void updateSidebarView((draft): void => {
173
- draft.open = !draft.open
174
- }).catch((error: unknown): void => {
175
- ctx.ui.toast.show({
176
- title: "Todo sidebar",
177
- message: `Cannot save expanded state: ${messageFor(error)}`,
178
- variant: "error",
179
- })
180
- })
181
- }}
182
- >
183
- <Show when={!props.full}>
184
- <text fg={ctx.theme.text.base}>{sidebarView.open ? "▼" : "▶"}</text>
185
- </Show>
186
- <text fg={ctx.theme.text.base}>
187
- <b>TODO</b>
188
- <span style={{ fg: ctx.theme.text.muted }}>{" "}({active()} remaining)</span>
159
+ const Header = (): JSX.Element => (
160
+ <box
161
+ flexDirection="row"
162
+ gap={1}
163
+ flexShrink={0}
164
+ paddingBottom={props.full ? 1 : 0}
165
+ onMouseDown={(event: MouseEvent): void => {
166
+ if (props.full) return
167
+ event.stopPropagation()
168
+ void updateSidebarView((draft): void => {
169
+ draft.open = !draft.open
170
+ }).catch((error: unknown): void => {
171
+ ctx.ui.toast.show({
172
+ title: "Todo sidebar",
173
+ message: `Cannot save expanded state: ${messageFor(error)}`,
174
+ variant: "error",
175
+ })
176
+ })
177
+ }}
178
+ >
179
+ <Show when={!props.full}>
180
+ <text fg={ctx.theme.text.base}>{sidebarView.open ? "▼" : "▶"}</text>
181
+ </Show>
182
+ <box flexGrow={1} flexShrink={1} minWidth={0}>
183
+ <text fg={ctx.theme.text.base}>
184
+ <b>TODO</b>
185
+ <span style={{ fg: ctx.theme.text.muted }}>{" "}({active()} remaining)</span>
186
+ </text>
187
+ </box>
188
+ <Show when={props.full ? props.onClose : undefined}>
189
+ {(onClose: Accessor<() => void>): JSX.Element => (
190
+ <text
191
+ fg={ctx.theme.text.muted}
192
+ flexShrink={0}
193
+ selectable={false}
194
+ onMouseDown={(event: MouseEvent): void => {
195
+ event.stopPropagation()
196
+ onClose()()
197
+ }}
198
+ >
199
+ ×
189
200
  </text>
190
- </box>
191
- <Show when={props.full || sidebarView.open}>
192
- <Show when={props.full}>
193
- <text fg={ctx.theme.text.muted}>{snapshot()?.path ?? "Loading todo.txt…"}</text>
194
- </Show>
195
- <Show when={error()}>
196
- <text fg={ctx.theme.text.feedback.error.base}>Cannot read todos: {error()}</text>
197
- </Show>
198
- <Show when={props.full && view().status === "ready" && todos().length === 0}>
199
- <text fg={ctx.theme.text.muted}>No todos for this session.</text>
200
- </Show>
201
+ )}
202
+ </Show>
203
+ </box>
204
+ )
205
+ const Body = (): JSX.Element => (
206
+ <>
207
+ <Show when={error()}>
208
+ <text fg={ctx.theme.text.feedback.error.base}>Cannot read todos: {error()}</text>
209
+ </Show>
210
+ <Show when={props.full && view().status === "ready" && todos().length === 0}>
211
+ <text fg={ctx.theme.text.muted}>No todos for this session.</text>
212
+ </Show>
213
+ <Show
214
+ when={props.full}
215
+ fallback={
201
216
  <For each={todos()}>
202
217
  {(todo): JSX.Element => (
203
- <text fg={colorFor(todo.status, ctx.theme)}>
204
- {formatTodo(todo)}
205
- </text>
218
+ <text fg={colorFor(todo.status, ctx.theme)}>{formatTodo(todo)}</text>
206
219
  )}
207
220
  </For>
208
- <Show when={more()}>
209
- <text
210
- fg={ctx.theme.text.feedback.info.base}
211
- onMouseDown={(event: MouseEvent): void => {
212
- event.stopPropagation()
213
- ctx.ui.panel.open("session-todo.list")
214
- }}
215
- >
216
- Show all {more()} todos →
217
- </text>
218
- </Show>
219
- </Show>
220
- </box>
221
+ }
222
+ >
223
+ <For each={todos()}>
224
+ {(todo): JSX.Element => (
225
+ <box flexDirection="row" gap={1}>
226
+ <text
227
+ fg={colorFor(todo.status, ctx.theme)}
228
+ width={3}
229
+ flexShrink={0}
230
+ >
231
+ {todoSymbols[todo.status]}
232
+ </text>
233
+ <box flexGrow={1} flexShrink={1} minWidth={0}>
234
+ <text fg={colorFor(todo.status, ctx.theme)} wrapMode="word">
235
+ {todo.content}
236
+ </text>
237
+ </box>
238
+ <text fg={ctx.theme.text.muted} width={3} flexShrink={0}>
239
+ {priorityBadge[todo.priority]}
240
+ </text>
241
+ </box>
242
+ )}
243
+ </For>
244
+ </Show>
245
+ <Show when={more()}>
246
+ <text
247
+ fg={ctx.theme.text.feedback.info.base}
248
+ onMouseDown={(event: MouseEvent): void => {
249
+ event.stopPropagation()
250
+ ctx.ui.panel.open("session-todo.list")
251
+ }}
252
+ >
253
+ Show all {more()} todos →
254
+ </text>
255
+ </Show>
256
+ </>
257
+ )
258
+ return (
259
+ <Show when={visible()}>
260
+ <Show
261
+ when={props.full}
262
+ fallback={
263
+ <box flexDirection="column" gap={0} paddingTop={1}>
264
+ <Header />
265
+ <Show when={sidebarView.open}>
266
+ <Body />
267
+ </Show>
268
+ </box>
269
+ }
270
+ >
271
+ <box flexDirection="column" flexGrow={1} minHeight={0} padding={1}>
272
+ <Header />
273
+ <scrollbox flexGrow={1} flexBasis={0} minHeight={0}>
274
+ <Body />
275
+ </scrollbox>
276
+ </box>
277
+ </Show>
221
278
  </Show>
222
279
  )
223
280
  }
@@ -240,22 +297,7 @@ export default Plugin.define({
240
297
  ],
241
298
  }),
242
299
  )
243
- return (
244
- <box flexDirection="column" flexGrow={1} padding={1}>
245
- <text
246
- fg={ctx.theme.text.base}
247
- onMouseDown={(event: MouseEvent): void => {
248
- event.stopPropagation()
249
- props.close()
250
- }}
251
- >
252
- Close × · Esc when focused · Ctrl+P to toggle
253
- </text>
254
- <scrollbox flexGrow={1}>
255
- <List sessionID={props.sessionID} full />
256
- </scrollbox>
257
- </box>
258
- )
300
+ return <List sessionID={props.sessionID} full onClose={props.close} />
259
301
  }
260
302
  const panel: () => void = ctx.ui.slot({
261
303
  append: "session.panel",
@@ -287,7 +329,6 @@ export default Plugin.define({
287
329
  return (): void => {
288
330
  sidebar()
289
331
  panel()
290
- notify()
291
332
  }
292
333
  },
293
334
  })
package/USAGE.md DELETED
@@ -1,83 +0,0 @@
1
- # Session TODOs
2
-
3
- ## Setup
4
-
5
- Configure the plugin in `opencode.jsonc`:
6
-
7
- ```json
8
- {
9
- "$schema": "https://opencode.ai/config.json",
10
- "plugins": [{ "package": "opencode-session-todo", "options": { "sidebarLimit": 5 } }]
11
- }
12
- ```
13
-
14
- Then reload OpenCode:
15
-
16
- ```sh
17
- opencode reload
18
- ```
19
-
20
- Reopen the TUI after configuration changes. `sidebarLimit` is a positive integer, default **5**.
21
-
22
- ## Tools
23
-
24
- Only root sessions expose these tools and receive the TODO lifecycle instructions. Subagents see neither tool.
25
-
26
- - `todoread({})` reads the current session's tasks and file path. Missing files return an empty list.
27
- - `todowrite({ previous, todos })` replaces the ordered list only when the stored list still equals `previous`; take `previous` from the latest `todoread` or `todowrite` result. A mismatch fails with an instruction to `todoread` and retry. The result reports a before/after diff. An empty `todos` array clears the list.
28
-
29
- ```json
30
- {
31
- "previous": [],
32
- "todos": [
33
- { "content": "Run the tests", "status": "in_progress", "priority": "high" }
34
- ]
35
- }
36
- ```
37
-
38
- Statuses: `pending`, `in_progress`, `completed`, `cancelled`. Priorities: `high`, `medium`, `low`.
39
- At most one task may be `in_progress`. Content must be one nonempty trimmed line,
40
- cannot begin with a priority/date, and cannot contain `status:` or `pri:` tokens.
41
-
42
- ## TUI
43
-
44
- - Click **TODO** or its `▼` / `▶` arrow to expand or collapse the sidebar list.
45
- - Above `sidebarLimit` total tasks, the sidebar shows only unfinished tasks, capped at the limit,
46
- with the active task first.
47
- - **Show all …**, `/todos`, or **Ctrl+P → Toggle session todos** opens the full scrollable panel.
48
- - Close with **Close ×**, **Esc** while focused, or the same command-palette action.
49
- - Update toasts show only added or changed tasks, one line each, with the new status and priority.
50
- - Manual file edits appear within two seconds. Collapsing does not change the tasks.
51
-
52
- ## Storage
53
-
54
- ```text
55
- <project/worktree>/.agents/sessions/<session-id>/todo.txt
56
- ```
57
-
58
- ```text
59
- (A) Run the tests status:in_progress
60
- (B) Review the changes
61
- x Read the docs pri:C
62
- x Superseded approach pri:B status:cancelled
63
- ```
64
-
65
- `A` / `B` / `C` map to high / medium / low; other priority letters are unsupported.
66
- Add `.agents/sessions/` to your project's ignore rules to keep these files out of version control.
67
-
68
- ## Permissions
69
-
70
- Both tools use `todowrite`. Writes require agent/session rules resolving to `allow`; `ask` and
71
- `deny` are rejected. Approval prompts, saved approvals, and experimental policies are unsupported.
72
- Read-only agents should deny `todowrite`.
73
-
74
- ## Commands
75
-
76
- From the repository root:
77
-
78
- ```sh
79
- bun run check
80
- bun test
81
- bun run format # dprint fmt
82
- bun run format:check # dprint check
83
- ```
@@ -1,28 +0,0 @@
1
- # Development
2
-
3
- Requires Bun >= 1.4.2.
4
-
5
- ```sh
6
- bun ci
7
- make test
8
- ```
9
-
10
- `make test` runs typecheck, tests, and formatting checks.
11
- `make upgrade-dependencies` updates pinned dependencies and the lockfile.
12
-
13
- ## Package entry points
14
-
15
- The package ships TypeScript sources for OpenCode's Bun runtime and precompiled Solid ESM for the TUI. Run `bun run build` before loading the local package; packing builds the TUI automatically.
16
-
17
- - `opencode-session-todo`: tools and RPC handlers.
18
- - `opencode-session-todo/tui`: sidebar and full panel.
19
- - `opencode-session-todo/rpc`: shared RPC definitions.
20
-
21
- ## Publishing
22
-
23
- ```sh
24
- npm login
25
- make publish
26
- ```
27
-
28
- `make publish` runs the checks and a package dry-run before `npm publish --access public`.