opencode-session-todo 0.2.1 → 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
  });
@@ -215,127 +220,215 @@ var tui_default = Plugin.define({
215
220
  return current.status === "error" ? current.message : undefined;
216
221
  };
217
222
  const visible = () => Boolean(props.full || snapshot()?.todos.length || error());
218
- 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, {
219
297
  get when() {
220
- return visible();
298
+ return error();
221
299
  },
222
300
  get children() {
223
- 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)`);
224
- _$insertNode(_el$, _el$2);
225
- _$setProp(_el$, "flexDirection", "column");
226
- _$setProp(_el$, "gap", 0);
227
- _$setProp(_el$, "paddingTop", 1);
228
- _$insertNode(_el$2, _el$4);
229
- _$setProp(_el$2, "flexDirection", "row");
230
- _$setProp(_el$2, "gap", 1);
231
- _$setProp(_el$2, "onMouseDown", (event) => {
232
- if (props.full)
233
- return;
234
- event.stopPropagation();
235
- updateSidebarView((draft) => {
236
- draft.open = !draft.open;
237
- }).catch((error) => {
238
- ctx.ui.toast.show({
239
- title: "Todo sidebar",
240
- message: `Cannot save expanded state: ${messageFor(error)}`,
241
- 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
242
368
  });
243
- });
369
+ return _el$19;
370
+ })()
244
371
  });
245
- _$insert(_el$2, _$createComponent(Show, {
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");
384
+ });
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, {
246
396
  get when() {
247
- return !props.full;
397
+ return props.full;
248
398
  },
249
- get children() {
250
- var _el$3 = _$createElement("text");
251
- _$insert(_el$3, () => sidebarView.open ? "\u25BC" : "\u25B6");
252
- _$effect((_$p) => _$setProp(_el$3, "fg", ctx.theme.text.base, _$p));
253
- return _el$3;
254
- }
255
- }), _el$4);
256
- _$insertNode(_el$4, _el$5);
257
- _$insertNode(_el$4, _el$7);
258
- _$insertNode(_el$5, _$createTextNode(`TODO`));
259
- _$insertNode(_el$7, _el$8);
260
- _$insertNode(_el$7, _el$0);
261
- _$insert(_el$7, active, _el$0);
262
- _$insert(_el$, _$createComponent(Show, {
263
- get when() {
264
- 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
+ })();
265
416
  },
266
417
  get children() {
267
- return [_$createComponent(Show, {
268
- get when() {
269
- return props.full;
270
- },
271
- get children() {
272
- var _el$1 = _$createElement("text");
273
- _$insert(_el$1, () => snapshot()?.path ?? "Loading todo.txt\u2026");
274
- _$effect((_$p) => _$setProp(_el$1, "fg", ctx.theme.text.muted, _$p));
275
- return _el$1;
276
- }
277
- }), _$createComponent(Show, {
278
- get when() {
279
- return error();
280
- },
281
- get children() {
282
- var _el$10 = _$createElement("text"), _el$11 = _$createTextNode(`Cannot read todos: `);
283
- _$insertNode(_el$10, _el$11);
284
- _$insert(_el$10, error, null);
285
- _$effect((_$p) => _$setProp(_el$10, "fg", ctx.theme.text.feedback.error.base, _$p));
286
- return _el$10;
287
- }
288
- }), _$createComponent(Show, {
289
- get when() {
290
- return _$memo(() => !!(props.full && view().status === "ready"))() && todos().length === 0;
291
- },
292
- get children() {
293
- var _el$12 = _$createElement("text");
294
- _$insertNode(_el$12, _$createTextNode(`No todos for this session.`));
295
- _$effect((_$p) => _$setProp(_el$12, "fg", ctx.theme.text.muted, _$p));
296
- return _el$12;
297
- }
298
- }), _$createComponent(For, {
299
- get each() {
300
- return todos();
301
- },
302
- children: (todo) => (() => {
303
- var _el$17 = _$createElement("text");
304
- _$insert(_el$17, () => formatTodo(todo));
305
- _$effect((_$p) => _$setProp(_el$17, "fg", colorFor(todo.status, ctx.theme), _$p));
306
- return _el$17;
307
- })()
308
- }), _$createComponent(Show, {
309
- get when() {
310
- return more();
311
- },
312
- get children() {
313
- var _el$14 = _$createElement("text"), _el$15 = _$createTextNode(`Show all `), _el$16 = _$createTextNode(` todos \u2192`);
314
- _$insertNode(_el$14, _el$15);
315
- _$insertNode(_el$14, _el$16);
316
- _$setProp(_el$14, "onMouseDown", (event) => {
317
- event.stopPropagation();
318
- ctx.ui.panel.open("session-todo.list");
319
- });
320
- _$insert(_el$14, more, _el$16);
321
- _$effect((_$p) => _$setProp(_el$14, "fg", ctx.theme.text.feedback.info.base, _$p));
322
- return _el$14;
323
- }
324
- })];
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;
325
430
  }
326
- }), null);
327
- _$effect((_p$) => {
328
- var _v$ = ctx.theme.text.base, _v$2 = {
329
- fg: ctx.theme.text.muted
330
- };
331
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "fg", _v$, _p$.e));
332
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "style", _v$2, _p$.t));
333
- return _p$;
334
- }, {
335
- e: undefined,
336
- t: undefined
337
431
  });
338
- return _el$;
339
432
  }
340
433
  });
341
434
  };
@@ -357,28 +450,15 @@ var tui_default = Plugin.define({
357
450
  run: props.close
358
451
  }]
359
452
  }));
360
- return (() => {
361
- var _el$18 = _$createElement("box"), _el$19 = _$createElement("text"), _el$21 = _$createElement("scrollbox");
362
- _$insertNode(_el$18, _el$19);
363
- _$insertNode(_el$18, _el$21);
364
- _$setProp(_el$18, "flexDirection", "column");
365
- _$setProp(_el$18, "flexGrow", 1);
366
- _$setProp(_el$18, "padding", 1);
367
- _$insertNode(_el$19, _$createTextNode(`Close \xD7 \xB7 Esc when focused \xB7 Ctrl+P to toggle`));
368
- _$setProp(_el$19, "onMouseDown", (event) => {
369
- event.stopPropagation();
370
- props.close();
371
- });
372
- _$setProp(_el$21, "flexGrow", 1);
373
- _$insert(_el$21, _$createComponent(List, {
374
- get sessionID() {
375
- return props.sessionID;
376
- },
377
- full: true
378
- }));
379
- _$effect((_$p) => _$setProp(_el$19, "fg", ctx.theme.text.base, _$p));
380
- return _el$18;
381
- })();
453
+ return _$createComponent(List, {
454
+ get sessionID() {
455
+ return props.sessionID;
456
+ },
457
+ full: true,
458
+ get onClose() {
459
+ return props.close;
460
+ }
461
+ });
382
462
  };
383
463
  const panel = ctx.ui.slot({
384
464
  append: "session.panel",
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.1",
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
 
@@ -29,5 +29,30 @@ The following rules apply only when a TODO list is needed or already tracks unfi
29
29
  - Before your final response, you MUST reconcile the list with what was actually done and accurately retain unfinished work.
30
30
  - You SHOULD keep task descriptions concise and actionable.`
31
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
+
32
57
  export const TodoInstructions: string =
33
- `${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)
@@ -151,64 +156,125 @@ export default Plugin.define({
151
156
  }
152
157
  const visible: Accessor<boolean> = (): boolean =>
153
158
  Boolean(props.full || snapshot()?.todos.length || error())
154
- return (
155
- <Show when={visible()}>
156
- <box flexDirection="column" gap={0} paddingTop={1}>
157
- <box
158
- flexDirection="row"
159
- gap={1}
160
- onMouseDown={(event: MouseEvent): void => {
161
- if (props.full) return
162
- event.stopPropagation()
163
- void updateSidebarView((draft): void => {
164
- draft.open = !draft.open
165
- }).catch((error: unknown): void => {
166
- ctx.ui.toast.show({
167
- title: "Todo sidebar",
168
- message: `Cannot save expanded state: ${messageFor(error)}`,
169
- variant: "error",
170
- })
171
- })
172
- }}
173
- >
174
- <Show when={!props.full}>
175
- <text fg={ctx.theme.text.base}>{sidebarView.open ? "▼" : "▶"}</text>
176
- </Show>
177
- <text fg={ctx.theme.text.base}>
178
- <b>TODO</b>
179
- <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
+ ×
180
200
  </text>
181
- </box>
182
- <Show when={props.full || sidebarView.open}>
183
- <Show when={props.full}>
184
- <text fg={ctx.theme.text.muted}>{snapshot()?.path ?? "Loading todo.txt…"}</text>
185
- </Show>
186
- <Show when={error()}>
187
- <text fg={ctx.theme.text.feedback.error.base}>Cannot read todos: {error()}</text>
188
- </Show>
189
- <Show when={props.full && view().status === "ready" && todos().length === 0}>
190
- <text fg={ctx.theme.text.muted}>No todos for this session.</text>
191
- </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={
192
216
  <For each={todos()}>
193
217
  {(todo): JSX.Element => (
194
- <text fg={colorFor(todo.status, ctx.theme)}>
195
- {formatTodo(todo)}
196
- </text>
218
+ <text fg={colorFor(todo.status, ctx.theme)}>{formatTodo(todo)}</text>
197
219
  )}
198
220
  </For>
199
- <Show when={more()}>
200
- <text
201
- fg={ctx.theme.text.feedback.info.base}
202
- onMouseDown={(event: MouseEvent): void => {
203
- event.stopPropagation()
204
- ctx.ui.panel.open("session-todo.list")
205
- }}
206
- >
207
- Show all {more()} todos →
208
- </text>
209
- </Show>
210
- </Show>
211
- </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>
212
278
  </Show>
213
279
  )
214
280
  }
@@ -231,22 +297,7 @@ export default Plugin.define({
231
297
  ],
232
298
  }),
233
299
  )
234
- return (
235
- <box flexDirection="column" flexGrow={1} padding={1}>
236
- <text
237
- fg={ctx.theme.text.base}
238
- onMouseDown={(event: MouseEvent): void => {
239
- event.stopPropagation()
240
- props.close()
241
- }}
242
- >
243
- Close × · Esc when focused · Ctrl+P to toggle
244
- </text>
245
- <scrollbox flexGrow={1}>
246
- <List sessionID={props.sessionID} full />
247
- </scrollbox>
248
- </box>
249
- )
300
+ return <List sessionID={props.sessionID} full onClose={props.close} />
250
301
  }
251
302
  const panel: () => void = ctx.ui.slot({
252
303
  append: "session.panel",
package/USAGE.md DELETED
@@ -1,85 +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
- Agents start a TODO list when they have more than one work item to track, not for a straightforward
27
- question or a single work item. Existing unfinished work remains tracked through completion.
28
-
29
- - `todoread({})` reads the current session's tasks and file path. Missing files return an empty list.
30
- - `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.
31
-
32
- ```json
33
- {
34
- "previous": [],
35
- "todos": [
36
- { "content": "Run the tests", "status": "in_progress", "priority": "high" }
37
- ]
38
- }
39
- ```
40
-
41
- Statuses: `pending`, `in_progress`, `completed`, `cancelled`. Priorities: `high`, `medium`, `low`.
42
- At most one task may be `in_progress`. Content must be one nonempty trimmed line,
43
- cannot begin with a priority/date, and cannot contain `status:` or `pri:` tokens.
44
-
45
- ## TUI
46
-
47
- - Click **TODO** or its `▼` / `▶` arrow to expand or collapse the sidebar list.
48
- - Above `sidebarLimit` total tasks, the sidebar shows only unfinished tasks, capped at the limit,
49
- with the active task first.
50
- - **Show all …**, `/todos`, or **Ctrl+P → Toggle session todos** opens the full scrollable panel.
51
- - Close with **Close ×**, **Esc** while focused, or the same command-palette action.
52
- - Manual file edits appear within two seconds. Collapsing does not change the tasks.
53
-
54
- ## Storage
55
-
56
- ```text
57
- <project/worktree>/.agents/sessions/<session-id>/todo.txt
58
- ```
59
-
60
- ```text
61
- (A) Run the tests status:in_progress
62
- (B) Review the changes
63
- x Read the docs pri:C
64
- x Superseded approach pri:B status:cancelled
65
- ```
66
-
67
- `A` / `B` / `C` map to high / medium / low; other priority letters are unsupported.
68
- Add `.agents/sessions/` to your project's ignore rules to keep these files out of version control.
69
-
70
- ## Permissions
71
-
72
- Both tools use `todowrite`. Writes require agent/session rules resolving to `allow`; `ask` and
73
- `deny` are rejected. Approval prompts, saved approvals, and experimental policies are unsupported.
74
- Read-only agents should deny `todowrite`.
75
-
76
- ## Commands
77
-
78
- From the repository root:
79
-
80
- ```sh
81
- bun run check
82
- bun test
83
- bun run format # dprint fmt
84
- bun run format:check # dprint check
85
- ```
@@ -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`.