@encoredigitalgroup/opencode 0.2.4 → 0.2.6

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
@@ -32,7 +32,8 @@ The plugin resolves the mapped skills through OpenCode, so those skills can live
32
32
  ## Configuration
33
33
 
34
34
  Set `planDirectory` and `skillMappings` in the object's `options` field. `planDirectory` is a non-empty path relative to the workspace. `skillMappings` can override
35
- any supported command's installed skill name. Unspecified values use the defaults: `docs/_planning` and the command's existing skill name.
35
+ any supported command's installed skill name. `enforceTodoTracker` defaults to `true`; set it to `false` to disable tracker-refresh enforcement. Unspecified values
36
+ use `docs/_planning` and the command's existing skill name.
36
37
 
37
38
  ```json
38
39
  {
@@ -40,13 +41,14 @@ any supported command's installed skill name. Unspecified values use the default
40
41
  "plugins": [
41
42
  {
42
43
  "package": "@encoredigitalgroup/opencode",
43
- "options": {
44
- "planDirectory": "plans",
45
- "skillMappings": {
46
- "plan-review": "custom-plan-review",
47
- "plan-execute": "custom-plan-execute"
44
+ "options": {
45
+ "planDirectory": "plans",
46
+ "enforceTodoTracker": false,
47
+ "skillMappings": {
48
+ "plan-review": "custom-plan-review",
49
+ "plan-execute": "custom-plan-execute"
50
+ }
48
51
  }
49
- }
50
52
  }
51
53
  ]
52
54
  }
@@ -68,6 +70,13 @@ Each todo requires:
68
70
  The checklist renders only in the OpenCode terminal TUI sidebar. After a restart, it appears only when the completed `todotracker` result is in the host's loaded
69
71
  message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
70
72
 
73
+ After a session's first successful `todotracker` call, the plugin requires another update at least once per minute. When an update is due, it blocks every other
74
+ tool until the tracker is updated. A tracked subagent completing also requires its parent session to update the tracker before continuing. Enforcement is disabled
75
+ for sessions that have never used `todotracker`, and can be disabled for all sessions with `"enforceTodoTracker": false`.
76
+
77
+ When a root session is idle, the sidebar renders the final in-progress todo as completed only when every earlier todo is completed. It restores the original status
78
+ when work resumes.
79
+
71
80
  ## Development
72
81
 
73
82
  ```sh
@@ -4,6 +4,8 @@ import { type PluginConfig } from "./ConfigTypes.js";
4
4
  export declare class ConfigLoader {
5
5
  private static stringOption;
6
6
  private static planDirectoryOption;
7
+ private static booleanOption;
8
+ private static toolNamesOption;
7
9
  /** Get valid skill mappings from plugin options. */
8
10
  private static skillMappingsOption;
9
11
  /** Get the configured option values that can override the defaults. */
@@ -2,12 +2,22 @@ export declare const skillCommands: readonly ["feature-planning", "plan-execute"
2
2
  export type SkillCommand = (typeof skillCommands)[number];
3
3
  export type SkillMappings = Record<SkillCommand, string>;
4
4
  export type PluginConfig = {
5
+ enforceTodoTracker: boolean;
5
6
  planDirectory: string;
7
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
8
+ readOnlyTools: string[];
6
9
  skillMappings: SkillMappings;
10
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
11
+ writeCapableTools: string[];
7
12
  };
8
13
  export type UserPluginConfig = {
14
+ enforceTodoTracker?: boolean;
9
15
  planDirectory?: string;
16
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
17
+ readOnlyTools?: string[];
10
18
  skillMappings?: Partial<SkillMappings>;
19
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
20
+ writeCapableTools?: string[];
11
21
  };
12
22
  /** Configuration type utilities. */
13
23
  export declare class ConfigTypes {
@@ -1,5 +1,6 @@
1
1
  import type { SessionMessageInfo } from "@opencode/client";
2
2
  import type { Plugin as ServerPlugin } from "@opencode/plugin";
3
+ import type { PluginConfig } from "../config";
3
4
  export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
4
5
  export type TodoPriority = "high" | "medium" | "low";
5
6
  export type Todo = {
@@ -48,6 +49,6 @@ export declare function isTodoResultMetadata(value: unknown): value is TodoTrack
48
49
  export declare function todosFromMessages(messages: readonly SessionMessageInfo[]): readonly Todo[];
49
50
  export declare class TodoTracker {
50
51
  static marker(status: TodoStatus): string;
51
- static sidebar(messages: readonly SessionMessageInfo[]): readonly Todo[];
52
- static register(context: ServerPlugin.Context): Promise<import("@opencode/plugin/promise/registration").Registration>;
52
+ static sidebar(messages: readonly SessionMessageInfo[], completed?: boolean): readonly Todo[];
53
+ static register(context: ServerPlugin.Context, config: PluginConfig): Promise<import("@opencode/plugin/promise/registration").Registration>;
53
54
  }
@@ -15,8 +15,11 @@ class ConfigDefaults {
15
15
  }
16
16
  static getDefaultConfig() {
17
17
  return {
18
+ enforceTodoTracker: true,
18
19
  planDirectory: "docs/_planning",
19
- skillMappings: this.getDefaultSkillMappings()
20
+ readOnlyTools: [],
21
+ skillMappings: this.getDefaultSkillMappings(),
22
+ writeCapableTools: []
20
23
  };
21
24
  }
22
25
  }
@@ -39,14 +42,23 @@ class ConfigTypes {
39
42
  class ConfigMerger {
40
43
  static merge(userConfig) {
41
44
  const config = ConfigDefaults.getDefaultConfig();
45
+ if (userConfig.enforceTodoTracker !== undefined) {
46
+ config.enforceTodoTracker = userConfig.enforceTodoTracker;
47
+ }
42
48
  if (userConfig.planDirectory !== undefined) {
43
49
  config.planDirectory = userConfig.planDirectory;
44
50
  }
51
+ if (userConfig.readOnlyTools !== undefined) {
52
+ config.readOnlyTools = userConfig.readOnlyTools;
53
+ }
45
54
  if (userConfig.skillMappings !== undefined) {
46
55
  for (const command of ConfigTypes.getSkillCommands()) {
47
56
  config.skillMappings[command] = userConfig.skillMappings[command] ?? config.skillMappings[command];
48
57
  }
49
58
  }
59
+ if (userConfig.writeCapableTools !== undefined) {
60
+ config.writeCapableTools = userConfig.writeCapableTools;
61
+ }
50
62
  return config;
51
63
  }
52
64
  }
@@ -58,6 +70,11 @@ class ConfigValidator {
58
70
  if (config.planDirectory.trim() === "") {
59
71
  errors.push("Invalid planDirectory: must be a non-empty string.");
60
72
  }
73
+ for (const [option, tools] of [["readOnlyTools", config.readOnlyTools], ["writeCapableTools", config.writeCapableTools]]) {
74
+ if (tools.some((tool) => tool.trim() === "")) {
75
+ errors.push(`Invalid ${option}: tool names must be non-empty strings.`);
76
+ }
77
+ }
61
78
  for (const command of ConfigTypes.getSkillCommands()) {
62
79
  if (config.skillMappings[command].trim() === "") {
63
80
  errors.push(`Invalid skillMappings.${command}: must be a non-empty string.`);
@@ -84,6 +101,23 @@ class ConfigLoader {
84
101
  const directory = this.stringOption(value)?.replace(/\/+$/, "");
85
102
  return directory === "" ? undefined : directory;
86
103
  }
104
+ static booleanOption(value) {
105
+ return typeof value === "boolean" ? value : undefined;
106
+ }
107
+ static toolNamesOption(value) {
108
+ if (!Array.isArray(value)) {
109
+ return;
110
+ }
111
+ const toolNames = [];
112
+ for (const valueTool of value) {
113
+ const tool = this.stringOption(valueTool);
114
+ if (tool === undefined) {
115
+ return;
116
+ }
117
+ toolNames.push(tool);
118
+ }
119
+ return toolNames;
120
+ }
87
121
  static skillMappingsOption(value) {
88
122
  if (typeof value !== "object" || value === null) {
89
123
  return;
@@ -99,11 +133,17 @@ class ConfigLoader {
99
133
  return skillMappings;
100
134
  }
101
135
  static getUserConfig(options) {
136
+ const enforceTodoTracker = this.booleanOption(options?.enforceTodoTracker);
102
137
  const planDirectory = this.planDirectoryOption(options?.planDirectory);
138
+ const readOnlyTools = this.toolNamesOption(options?.readOnlyTools);
103
139
  const skillMappings = this.skillMappingsOption(options?.skillMappings);
140
+ const writeCapableTools = this.toolNamesOption(options?.writeCapableTools);
104
141
  return {
142
+ ...enforceTodoTracker === undefined ? {} : { enforceTodoTracker },
105
143
  ...planDirectory === undefined ? {} : { planDirectory },
106
- ...skillMappings === undefined ? {} : { skillMappings }
144
+ ...readOnlyTools === undefined ? {} : { readOnlyTools },
145
+ ...skillMappings === undefined ? {} : { skillMappings },
146
+ ...writeCapableTools === undefined ? {} : { writeCapableTools }
107
147
  };
108
148
  }
109
149
  static loadConfig(options) {
@@ -151,6 +191,7 @@ var todoTrackerInput = {
151
191
  };
152
192
  var todoTrackerName = "todotracker";
153
193
  var todoTrackerDescription = "Replace the current session's complete ordered todo list. " + "Pass every remaining todo on each call; pass an empty array to clear the list.";
194
+ var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
154
195
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
155
196
  var todoPriorities = ["high", "medium", "low"];
156
197
  function isRecord(value) {
@@ -210,6 +251,119 @@ function todosFromMessages(messages) {
210
251
  }
211
252
  return [];
212
253
  }
254
+ var trackerRefreshIntervalMs = 60000;
255
+ function refreshedTrackerState(refreshedAt = Date.now()) {
256
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
257
+ }
258
+ function trackerRefreshDue(state, now = Date.now()) {
259
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
260
+ }
261
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
262
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
263
+ }
264
+ function trackerStateFromMessages(messages) {
265
+ let state = { kind: "untracked" };
266
+ for (const message of messages) {
267
+ if (message.type !== "assistant") {
268
+ continue;
269
+ }
270
+ for (const part of message.content) {
271
+ if (part.type !== "tool" || part.state.status !== "completed") {
272
+ continue;
273
+ }
274
+ if (part.name === todoTrackerName) {
275
+ state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
276
+ }
277
+ }
278
+ }
279
+ return state;
280
+ }
281
+
282
+ class TodoTrackerEnforcement {
283
+ context;
284
+ states = new Map;
285
+ constructor(context) {
286
+ this.context = context;
287
+ }
288
+ async stateFor(sessionID) {
289
+ const existing = this.states.get(sessionID);
290
+ if (existing !== undefined) {
291
+ return existing;
292
+ }
293
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
294
+ this.states.set(sessionID, state);
295
+ return state;
296
+ }
297
+ async requireTrackerRefresh(sessionID) {
298
+ const state = await this.stateFor(sessionID);
299
+ if (state.kind === "tracked") {
300
+ state.refreshRequired = true;
301
+ }
302
+ }
303
+ async register() {
304
+ let before;
305
+ let after;
306
+ let context;
307
+ try {
308
+ before = await this.context.tool.hook("execute.before", async (event) => {
309
+ const state = await this.stateFor(event.sessionID);
310
+ if (requiresTrackerRefresh(state, event.tool)) {
311
+ throw new Error(trackerRefreshMessage);
312
+ }
313
+ });
314
+ after = await this.context.tool.hook("execute.after", async (event) => {
315
+ if (event.status !== "completed") {
316
+ return;
317
+ }
318
+ if (event.tool === todoTrackerName) {
319
+ this.states.set(event.sessionID, refreshedTrackerState());
320
+ }
321
+ });
322
+ context = await this.context.session.hook("context", async (event) => {
323
+ const state = await this.stateFor(event.sessionID);
324
+ if (trackerRefreshDue(state)) {
325
+ event.system.push({ type: "text", text: trackerRefreshMessage });
326
+ }
327
+ });
328
+ const beforeRegistration = before;
329
+ const afterRegistration = after;
330
+ const contextRegistration = context;
331
+ const controller = new AbortController;
332
+ const observer = this.observeSubagentCompletion(controller.signal);
333
+ return {
334
+ dispose: async () => {
335
+ controller.abort();
336
+ await Promise.all([
337
+ beforeRegistration.dispose(),
338
+ afterRegistration.dispose(),
339
+ contextRegistration.dispose(),
340
+ observer
341
+ ]);
342
+ }
343
+ };
344
+ } catch (error) {
345
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
346
+ throw error;
347
+ }
348
+ }
349
+ async observeSubagentCompletion(signal) {
350
+ try {
351
+ for await (const event of this.context.event.subscribe({ signal })) {
352
+ if (event.type !== "session.idle") {
353
+ continue;
354
+ }
355
+ const session = await this.context.session.get({ sessionID: event.data.sessionID });
356
+ if (session.parentID !== undefined) {
357
+ await this.requireTrackerRefresh(session.parentID);
358
+ }
359
+ }
360
+ } catch (error) {
361
+ if (!signal.aborted) {
362
+ console.error("Unable to watch subagent completion for todo enforcement.", error);
363
+ }
364
+ }
365
+ }
366
+ }
213
367
 
214
368
  class TodoTracker {
215
369
  static marker(status) {
@@ -224,11 +378,16 @@ class TodoTracker {
224
378
  return "[-]";
225
379
  }
226
380
  }
227
- static sidebar(messages) {
228
- return todosFromMessages(messages);
381
+ static sidebar(messages, completed = false) {
382
+ const todos = todosFromMessages(messages);
383
+ const finalTodo = todos.at(-1);
384
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
385
+ return todos;
386
+ }
387
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
229
388
  }
230
- static register(context) {
231
- return context.tool.transform((tools) => {
389
+ static async register(context, config) {
390
+ const tool = await context.tool.transform((tools) => {
232
391
  tools.add({
233
392
  name: todoTrackerName,
234
393
  description: todoTrackerDescription,
@@ -242,6 +401,24 @@ class TodoTracker {
242
401
  }
243
402
  });
244
403
  });
404
+ if (!config.enforceTodoTracker) {
405
+ return tool;
406
+ }
407
+ try {
408
+ const enforcement = await new TodoTrackerEnforcement(context).register();
409
+ return {
410
+ dispose: async () => {
411
+ try {
412
+ await enforcement.dispose();
413
+ } finally {
414
+ await tool.dispose();
415
+ }
416
+ }
417
+ };
418
+ } catch (error) {
419
+ await tool.dispose();
420
+ throw error;
421
+ }
245
422
  }
246
423
  }
247
424
 
@@ -273,7 +450,7 @@ var server_default = Plugin.define({
273
450
  });
274
451
  let toolRegistration;
275
452
  try {
276
- toolRegistration = await TodoTracker.register(ctx);
453
+ toolRegistration = await TodoTracker.register(ctx, pluginConfig);
277
454
  } catch (error) {
278
455
  await commandRegistration.dispose();
279
456
  throw error;
package/dist/tui/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @bun
2
2
  // src/tui/index.tsx
3
3
  import { Plugin } from "@opencode/plugin/tui";
4
+ import { fg, StyledText } from "@opentui/core";
4
5
 
5
6
  // node_modules/solid-js/dist/solid.js
6
7
  var sharedConfig = {
@@ -48,22 +49,6 @@ var Listener = null;
48
49
  var Updates = null;
49
50
  var Effects = null;
50
51
  var ExecCount = 0;
51
- function createRoot(fn, detachedOwner) {
52
- const listener = Listener, owner = Owner, unowned = fn.length === 0, current = detachedOwner === undefined ? owner : detachedOwner, root = unowned ? UNOWNED : {
53
- owned: null,
54
- cleanups: null,
55
- context: current ? current.context : null,
56
- owner: current
57
- }, updateFn = unowned ? fn : () => fn(() => untrack(() => cleanNode(root)));
58
- Owner = root;
59
- Listener = null;
60
- try {
61
- return runUpdates(updateFn, true);
62
- } finally {
63
- Listener = listener;
64
- Owner = owner;
65
- }
66
- }
67
52
  function createSignal(value, options) {
68
53
  options = options ? Object.assign({}, signalOptions, options) : signalOptions;
69
54
  const s = {
@@ -92,19 +77,6 @@ function createEffect(fn, value, options) {
92
77
  c.user = true;
93
78
  Effects ? Effects.push(c) : updateComputation(c);
94
79
  }
95
- function createMemo(fn, value, options) {
96
- options = options ? Object.assign({}, signalOptions, options) : signalOptions;
97
- const c = createComputation(fn, value, true, 0);
98
- c.observers = null;
99
- c.observerSlots = null;
100
- c.comparator = options.equals || undefined;
101
- if (Scheduler && Transition && Transition.running) {
102
- c.tState = STALE;
103
- Updates.push(c);
104
- } else
105
- updateComputation(c);
106
- return readSignal.bind(c);
107
- }
108
80
  function untrack(fn) {
109
81
  if (!ExternalSourceConfig && Listener === null)
110
82
  return fn();
@@ -623,127 +595,6 @@ function handleError(err, owner = Owner) {
623
595
  runErrors(error, fns, owner);
624
596
  }
625
597
  var FALLBACK = Symbol("fallback");
626
- function dispose(d) {
627
- for (let i = 0;i < d.length; i++)
628
- d[i]();
629
- }
630
- function mapArray(list, mapFn, options = {}) {
631
- let items = [], mapped = [], disposers = [], len = 0, indexes = mapFn.length > 1 ? [] : null;
632
- onCleanup(() => dispose(disposers));
633
- return () => {
634
- let newItems = list() || [], newLen = newItems.length, i, j;
635
- newItems[$TRACK];
636
- return untrack(() => {
637
- let newIndices, newIndicesNext, temp, tempdisposers, tempIndexes, start, end, newEnd, item;
638
- if (newLen === 0) {
639
- if (len !== 0) {
640
- dispose(disposers);
641
- disposers = [];
642
- items = [];
643
- mapped = [];
644
- len = 0;
645
- indexes && (indexes = []);
646
- }
647
- if (options.fallback) {
648
- items = [FALLBACK];
649
- mapped[0] = createRoot((disposer) => {
650
- disposers[0] = disposer;
651
- return options.fallback();
652
- });
653
- len = 1;
654
- }
655
- } else if (len === 0) {
656
- mapped = new Array(newLen);
657
- for (j = 0;j < newLen; j++) {
658
- items[j] = newItems[j];
659
- mapped[j] = createRoot(mapper);
660
- }
661
- len = newLen;
662
- } else {
663
- temp = new Array(newLen);
664
- tempdisposers = new Array(newLen);
665
- indexes && (tempIndexes = new Array(newLen));
666
- for (start = 0, end = Math.min(len, newLen);start < end && items[start] === newItems[start]; start++)
667
- ;
668
- for (end = len - 1, newEnd = newLen - 1;end >= start && newEnd >= start && items[end] === newItems[newEnd]; end--, newEnd--) {
669
- temp[newEnd] = mapped[end];
670
- tempdisposers[newEnd] = disposers[end];
671
- indexes && (tempIndexes[newEnd] = indexes[end]);
672
- }
673
- newIndices = new Map;
674
- newIndicesNext = new Array(newEnd + 1);
675
- for (j = newEnd;j >= start; j--) {
676
- item = newItems[j];
677
- i = newIndices.get(item);
678
- newIndicesNext[j] = i === undefined ? -1 : i;
679
- newIndices.set(item, j);
680
- }
681
- for (i = start;i <= end; i++) {
682
- item = items[i];
683
- j = newIndices.get(item);
684
- if (j !== undefined && j !== -1) {
685
- temp[j] = mapped[i];
686
- tempdisposers[j] = disposers[i];
687
- indexes && (tempIndexes[j] = indexes[i]);
688
- j = newIndicesNext[j];
689
- newIndices.set(item, j);
690
- } else
691
- disposers[i]();
692
- }
693
- for (j = start;j < newLen; j++) {
694
- if (j in temp) {
695
- mapped[j] = temp[j];
696
- disposers[j] = tempdisposers[j];
697
- if (indexes) {
698
- indexes[j] = tempIndexes[j];
699
- indexes[j](j);
700
- }
701
- } else
702
- mapped[j] = createRoot(mapper);
703
- }
704
- mapped = mapped.slice(0, len = newLen);
705
- items = newItems.slice(0);
706
- }
707
- return mapped;
708
- });
709
- function mapper(disposer) {
710
- disposers[j] = disposer;
711
- if (indexes) {
712
- const [s, set] = createSignal(j);
713
- indexes[j] = set;
714
- return mapFn(newItems[j], s);
715
- }
716
- return mapFn(newItems[j]);
717
- }
718
- };
719
- }
720
- var narrowedError = (name) => `Stale read from <${name}>.`;
721
- function For(props) {
722
- const fallback = "fallback" in props && {
723
- fallback: () => props.fallback
724
- };
725
- return createMemo(mapArray(() => props.each, props.children, fallback || undefined));
726
- }
727
- function Show(props) {
728
- const keyed = props.keyed;
729
- const conditionValue = createMemo(() => props.when, undefined, undefined);
730
- const condition = keyed ? conditionValue : createMemo(conditionValue, undefined, {
731
- equals: (a, b) => !a === !b
732
- });
733
- return createMemo(() => {
734
- const c = condition();
735
- if (c) {
736
- const child = props.children;
737
- const fn = typeof child === "function" && child.length > 0;
738
- return fn ? untrack(() => child(keyed ? c : () => {
739
- if (!untrack(condition))
740
- throw narrowedError("Show");
741
- return conditionValue();
742
- })) : child;
743
- }
744
- return props.fallback;
745
- }, undefined, undefined);
746
- }
747
598
 
748
599
  // src/config/ConfigDefaults.ts
749
600
  class ConfigDefaults {
@@ -758,8 +609,11 @@ class ConfigDefaults {
758
609
  }
759
610
  static getDefaultConfig() {
760
611
  return {
612
+ enforceTodoTracker: true,
761
613
  planDirectory: "docs/_planning",
762
- skillMappings: this.getDefaultSkillMappings()
614
+ readOnlyTools: [],
615
+ skillMappings: this.getDefaultSkillMappings(),
616
+ writeCapableTools: []
763
617
  };
764
618
  }
765
619
  }
@@ -782,14 +636,23 @@ class ConfigTypes {
782
636
  class ConfigMerger {
783
637
  static merge(userConfig) {
784
638
  const config = ConfigDefaults.getDefaultConfig();
639
+ if (userConfig.enforceTodoTracker !== undefined) {
640
+ config.enforceTodoTracker = userConfig.enforceTodoTracker;
641
+ }
785
642
  if (userConfig.planDirectory !== undefined) {
786
643
  config.planDirectory = userConfig.planDirectory;
787
644
  }
645
+ if (userConfig.readOnlyTools !== undefined) {
646
+ config.readOnlyTools = userConfig.readOnlyTools;
647
+ }
788
648
  if (userConfig.skillMappings !== undefined) {
789
649
  for (const command of ConfigTypes.getSkillCommands()) {
790
650
  config.skillMappings[command] = userConfig.skillMappings[command] ?? config.skillMappings[command];
791
651
  }
792
652
  }
653
+ if (userConfig.writeCapableTools !== undefined) {
654
+ config.writeCapableTools = userConfig.writeCapableTools;
655
+ }
793
656
  return config;
794
657
  }
795
658
  }
@@ -801,6 +664,11 @@ class ConfigValidator {
801
664
  if (config.planDirectory.trim() === "") {
802
665
  errors.push("Invalid planDirectory: must be a non-empty string.");
803
666
  }
667
+ for (const [option, tools] of [["readOnlyTools", config.readOnlyTools], ["writeCapableTools", config.writeCapableTools]]) {
668
+ if (tools.some((tool) => tool.trim() === "")) {
669
+ errors.push(`Invalid ${option}: tool names must be non-empty strings.`);
670
+ }
671
+ }
804
672
  for (const command of ConfigTypes.getSkillCommands()) {
805
673
  if (config.skillMappings[command].trim() === "") {
806
674
  errors.push(`Invalid skillMappings.${command}: must be a non-empty string.`);
@@ -827,6 +695,23 @@ class ConfigLoader {
827
695
  const directory = this.stringOption(value)?.replace(/\/+$/, "");
828
696
  return directory === "" ? undefined : directory;
829
697
  }
698
+ static booleanOption(value) {
699
+ return typeof value === "boolean" ? value : undefined;
700
+ }
701
+ static toolNamesOption(value) {
702
+ if (!Array.isArray(value)) {
703
+ return;
704
+ }
705
+ const toolNames = [];
706
+ for (const valueTool of value) {
707
+ const tool = this.stringOption(valueTool);
708
+ if (tool === undefined) {
709
+ return;
710
+ }
711
+ toolNames.push(tool);
712
+ }
713
+ return toolNames;
714
+ }
830
715
  static skillMappingsOption(value) {
831
716
  if (typeof value !== "object" || value === null) {
832
717
  return;
@@ -842,11 +727,17 @@ class ConfigLoader {
842
727
  return skillMappings;
843
728
  }
844
729
  static getUserConfig(options) {
730
+ const enforceTodoTracker = this.booleanOption(options?.enforceTodoTracker);
845
731
  const planDirectory = this.planDirectoryOption(options?.planDirectory);
732
+ const readOnlyTools = this.toolNamesOption(options?.readOnlyTools);
846
733
  const skillMappings = this.skillMappingsOption(options?.skillMappings);
734
+ const writeCapableTools = this.toolNamesOption(options?.writeCapableTools);
847
735
  return {
736
+ ...enforceTodoTracker === undefined ? {} : { enforceTodoTracker },
848
737
  ...planDirectory === undefined ? {} : { planDirectory },
849
- ...skillMappings === undefined ? {} : { skillMappings }
738
+ ...readOnlyTools === undefined ? {} : { readOnlyTools },
739
+ ...skillMappings === undefined ? {} : { skillMappings },
740
+ ...writeCapableTools === undefined ? {} : { writeCapableTools }
850
741
  };
851
742
  }
852
743
  static loadConfig(options) {
@@ -1085,6 +976,7 @@ var todoTrackerInput = {
1085
976
  };
1086
977
  var todoTrackerName = "todotracker";
1087
978
  var todoTrackerDescription = "Replace the current session's complete ordered todo list. " + "Pass every remaining todo on each call; pass an empty array to clear the list.";
979
+ var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
1088
980
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
1089
981
  var todoPriorities = ["high", "medium", "low"];
1090
982
  function isRecord(value) {
@@ -1144,6 +1036,119 @@ function todosFromMessages(messages) {
1144
1036
  }
1145
1037
  return [];
1146
1038
  }
1039
+ var trackerRefreshIntervalMs = 60000;
1040
+ function refreshedTrackerState(refreshedAt = Date.now()) {
1041
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
1042
+ }
1043
+ function trackerRefreshDue(state, now = Date.now()) {
1044
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
1045
+ }
1046
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
1047
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
1048
+ }
1049
+ function trackerStateFromMessages(messages) {
1050
+ let state = { kind: "untracked" };
1051
+ for (const message of messages) {
1052
+ if (message.type !== "assistant") {
1053
+ continue;
1054
+ }
1055
+ for (const part of message.content) {
1056
+ if (part.type !== "tool" || part.state.status !== "completed") {
1057
+ continue;
1058
+ }
1059
+ if (part.name === todoTrackerName) {
1060
+ state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
1061
+ }
1062
+ }
1063
+ }
1064
+ return state;
1065
+ }
1066
+
1067
+ class TodoTrackerEnforcement {
1068
+ context;
1069
+ states = new Map;
1070
+ constructor(context) {
1071
+ this.context = context;
1072
+ }
1073
+ async stateFor(sessionID) {
1074
+ const existing = this.states.get(sessionID);
1075
+ if (existing !== undefined) {
1076
+ return existing;
1077
+ }
1078
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
1079
+ this.states.set(sessionID, state);
1080
+ return state;
1081
+ }
1082
+ async requireTrackerRefresh(sessionID) {
1083
+ const state = await this.stateFor(sessionID);
1084
+ if (state.kind === "tracked") {
1085
+ state.refreshRequired = true;
1086
+ }
1087
+ }
1088
+ async register() {
1089
+ let before;
1090
+ let after;
1091
+ let context;
1092
+ try {
1093
+ before = await this.context.tool.hook("execute.before", async (event) => {
1094
+ const state = await this.stateFor(event.sessionID);
1095
+ if (requiresTrackerRefresh(state, event.tool)) {
1096
+ throw new Error(trackerRefreshMessage);
1097
+ }
1098
+ });
1099
+ after = await this.context.tool.hook("execute.after", async (event) => {
1100
+ if (event.status !== "completed") {
1101
+ return;
1102
+ }
1103
+ if (event.tool === todoTrackerName) {
1104
+ this.states.set(event.sessionID, refreshedTrackerState());
1105
+ }
1106
+ });
1107
+ context = await this.context.session.hook("context", async (event) => {
1108
+ const state = await this.stateFor(event.sessionID);
1109
+ if (trackerRefreshDue(state)) {
1110
+ event.system.push({ type: "text", text: trackerRefreshMessage });
1111
+ }
1112
+ });
1113
+ const beforeRegistration = before;
1114
+ const afterRegistration = after;
1115
+ const contextRegistration = context;
1116
+ const controller = new AbortController;
1117
+ const observer = this.observeSubagentCompletion(controller.signal);
1118
+ return {
1119
+ dispose: async () => {
1120
+ controller.abort();
1121
+ await Promise.all([
1122
+ beforeRegistration.dispose(),
1123
+ afterRegistration.dispose(),
1124
+ contextRegistration.dispose(),
1125
+ observer
1126
+ ]);
1127
+ }
1128
+ };
1129
+ } catch (error) {
1130
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
1131
+ throw error;
1132
+ }
1133
+ }
1134
+ async observeSubagentCompletion(signal) {
1135
+ try {
1136
+ for await (const event of this.context.event.subscribe({ signal })) {
1137
+ if (event.type !== "session.idle") {
1138
+ continue;
1139
+ }
1140
+ const session = await this.context.session.get({ sessionID: event.data.sessionID });
1141
+ if (session.parentID !== undefined) {
1142
+ await this.requireTrackerRefresh(session.parentID);
1143
+ }
1144
+ }
1145
+ } catch (error) {
1146
+ if (!signal.aborted) {
1147
+ console.error("Unable to watch subagent completion for todo enforcement.", error);
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1147
1152
 
1148
1153
  class TodoTracker {
1149
1154
  static marker(status) {
@@ -1158,11 +1163,16 @@ class TodoTracker {
1158
1163
  return "[-]";
1159
1164
  }
1160
1165
  }
1161
- static sidebar(messages) {
1162
- return todosFromMessages(messages);
1166
+ static sidebar(messages, completed = false) {
1167
+ const todos = todosFromMessages(messages);
1168
+ const finalTodo = todos.at(-1);
1169
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
1170
+ return todos;
1171
+ }
1172
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
1163
1173
  }
1164
- static register(context) {
1165
- return context.tool.transform((tools) => {
1174
+ static async register(context, config) {
1175
+ const tool = await context.tool.transform((tools) => {
1166
1176
  tools.add({
1167
1177
  name: todoTrackerName,
1168
1178
  description: todoTrackerDescription,
@@ -1176,6 +1186,24 @@ class TodoTracker {
1176
1186
  }
1177
1187
  });
1178
1188
  });
1189
+ if (!config.enforceTodoTracker) {
1190
+ return tool;
1191
+ }
1192
+ try {
1193
+ const enforcement = await new TodoTrackerEnforcement(context).register();
1194
+ return {
1195
+ dispose: async () => {
1196
+ try {
1197
+ await enforcement.dispose();
1198
+ } finally {
1199
+ await tool.dispose();
1200
+ }
1201
+ }
1202
+ };
1203
+ } catch (error) {
1204
+ await tool.dispose();
1205
+ throw error;
1206
+ }
1179
1207
  }
1180
1208
  }
1181
1209
 
@@ -1186,28 +1214,52 @@ function PickerCommandLayer(props) {
1186
1214
  return /* @__PURE__ */ jsx(Fragment, {});
1187
1215
  }
1188
1216
  function TodoSidebar(props) {
1189
- const todos = createMemo(() => TodoTracker.sidebar(props.context.data.session.message.list(props.sessionID)));
1217
+ const todos = () => TodoTracker.sidebar(props.context.data.session.message.list(props.sessionID), props.context.data.session.status(props.sessionID) === "idle");
1190
1218
  const color = (status) => status === "in_progress" ? props.context.theme.text.feedback.warning.base : props.context.theme.text.muted;
1219
+ const line = (todo) => `${TodoTracker.marker(todo.status)} (${todo.priority}) ${todo.content}`;
1220
+ const plainContent = (items) => items.map(line).join(`
1221
+ `);
1222
+ const content = (items) => new StyledText(items.flatMap((todo, index) => [
1223
+ fg(color(todo.status))(`${line(todo)}${index === items.length - 1 ? "" : `
1224
+ `}`)
1225
+ ]));
1226
+ let heading;
1227
+ let list;
1228
+ const render = () => {
1229
+ const items = todos();
1230
+ if (heading !== undefined) {
1231
+ heading.content = items.length > 0 ? "Todos" : "";
1232
+ }
1233
+ if (list !== undefined) {
1234
+ list.content = content(items);
1235
+ }
1236
+ };
1237
+ const initialTodos = todos();
1191
1238
  onMount(() => {
1239
+ render();
1240
+ const unlisten = props.context.data.listen?.(() => {
1241
+ render();
1242
+ });
1243
+ onCleanup(() => unlisten?.());
1192
1244
  props.context.data.session.message.sync(props.sessionID).catch(() => {});
1193
1245
  });
1194
- return /* @__PURE__ */ jsx(Show, {
1195
- when: todos().length > 0,
1196
- children: /* @__PURE__ */ jsxs("box", {
1197
- flexDirection: "column",
1198
- children: [
1199
- /* @__PURE__ */ jsx("text", {
1200
- children: "Todos"
1201
- }),
1202
- /* @__PURE__ */ jsx(For, {
1203
- each: todos(),
1204
- children: (todo) => /* @__PURE__ */ jsx("text", {
1205
- fg: color(todo.status),
1206
- children: `${TodoTracker.marker(todo.status)} (${todo.priority}) ${todo.content}`
1207
- })
1208
- })
1209
- ]
1210
- })
1246
+ return /* @__PURE__ */ jsxs("box", {
1247
+ flexDirection: "column",
1248
+ children: [
1249
+ /* @__PURE__ */ jsx("text", {
1250
+ ref: (node) => {
1251
+ heading = node;
1252
+ },
1253
+ children: initialTodos.length > 0 ? "Todos" : ""
1254
+ }),
1255
+ /* @__PURE__ */ jsx("text", {
1256
+ ref: (node) => {
1257
+ list = node;
1258
+ },
1259
+ fg: props.context.theme.text.muted,
1260
+ content: plainContent(initialTodos)
1261
+ })
1262
+ ]
1211
1263
  });
1212
1264
  }
1213
1265
  var tui_default = Plugin.define({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@encoredigitalgroup/opencode",
3
3
  "type": "module",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "description": "OpenCode file-picker commands for planning skills.",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -20,16 +20,16 @@
20
20
  "solid-js": "1.9.12"
21
21
  },
22
22
  "devDependencies": {
23
- "@opencode/cli": "2.0.22",
24
- "@opencode/client": "2.0.22",
25
- "@opencode/plugin": "2.0.22",
23
+ "@opencode/cli": "2.1.4",
24
+ "@opencode/client": "2.1.4",
25
+ "@opencode/plugin": "2.1.4",
26
26
  "@opentui/core": "0.5.14",
27
27
  "@types/bun": "1.4.0",
28
28
  "tsfmt": "^0.3.0-beta9",
29
29
  "typescript": "5.8.2"
30
30
  },
31
31
  "scripts": {
32
- "build": "rm -rf dist && bun build src/server/index.ts src/tui/index.tsx --outdir dist --target bun --external '@opencode/plugin' --external '@opencode/plugin/*' --external '@opentui/solid' --external '@opentui/solid/*' && tsc --emitDeclarationOnly",
32
+ "build": "rm -rf dist && bun build src/server/index.ts src/tui/index.tsx --outdir dist --target bun --external '@opencode/plugin' --external '@opencode/plugin/*' --external '@opentui/core' --external '@opentui/core/*' --external '@opentui/solid' --external '@opentui/solid/*' && tsc --emitDeclarationOnly",
33
33
  "check": "bun run typecheck && bun run build",
34
34
  "pack:check": "bun pm pack --dry-run",
35
35
  "test": "bun test",