@encoredigitalgroup/opencode 0.2.4 → 0.2.5

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,9 @@ 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. `readOnlyTools`
36
+ and `writeCapableTools` add custom tools to the built-in classifications. Unspecified values use the defaults: `docs/_planning`, the command's existing skill name,
37
+ and no custom tool classifications.
36
38
 
37
39
  ```json
38
40
  {
@@ -42,10 +44,13 @@ any supported command's installed skill name. Unspecified values use the default
42
44
  "package": "@encoredigitalgroup/opencode",
43
45
  "options": {
44
46
  "planDirectory": "plans",
47
+ "enforceTodoTracker": false,
48
+ "readOnlyTools": ["repository_status"],
45
49
  "skillMappings": {
46
50
  "plan-review": "custom-plan-review",
47
51
  "plan-execute": "custom-plan-execute"
48
- }
52
+ },
53
+ "writeCapableTools": ["deploy"]
49
54
  }
50
55
  }
51
56
  ]
@@ -68,6 +73,15 @@ Each todo requires:
68
73
  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
74
  message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
70
75
 
76
+ After a session's first successful `todotracker` call, the plugin requires another call before a later write-capable tool runs. OpenCode's built-in write-capable
77
+ tools are `edit`, `patch`, `shell`, `subagent`, and `write`. Built-in read-only tools are `glob`, `grep`, `lsp`, `read`, `skill`, `webfetch`, and `websearch`; the
78
+ eleventh read-only call requires a tracker update. Session and user-interaction tools, such as `question` and `todo`, are not enforced. `readOnlyTools` and
79
+ `writeCapableTools` extend these lists for custom tools. If a tool appears in both lists, the write-capable classification takes precedence. The plugin also supports
80
+ the `apply_patch` and `bash` compatibility aliases as write-capable tools. Enforcement is disabled for sessions that have never used `todotracker`, and can be
81
+ disabled for all sessions with `"enforceTodoTracker": false`.
82
+
83
+ When a session is idle, the sidebar renders pending and in-progress todos as completed. It restores their original statuses when work resumes.
84
+
71
85
  ## Development
72
86
 
73
87
  ```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,18 @@ 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
+ readOnlyTools: string[];
6
8
  skillMappings: SkillMappings;
9
+ writeCapableTools: string[];
7
10
  };
8
11
  export type UserPluginConfig = {
12
+ enforceTodoTracker?: boolean;
9
13
  planDirectory?: string;
14
+ readOnlyTools?: string[];
10
15
  skillMappings?: Partial<SkillMappings>;
16
+ writeCapableTools?: string[];
11
17
  };
12
18
  /** Configuration type utilities. */
13
19
  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,157 @@ function todosFromMessages(messages) {
210
251
  }
211
252
  return [];
212
253
  }
254
+ var builtInReadOnlyTools = new Set(["glob", "grep", "lsp", "read", "skill", "webfetch", "websearch"]);
255
+ var builtInWriteCapableTools = new Set(["edit", "patch", "shell", "subagent", "write"]);
256
+ var compatibilityWriteCapableTools = new Set(["apply_patch", "bash"]);
257
+ var readToolsBeforeRefresh = 10;
258
+ function refreshedTrackerState() {
259
+ return { kind: "tracked", readToolsSinceRefresh: 0, requiresRefreshBeforeWrite: false };
260
+ }
261
+ function isReadOnlyTool(tool, config) {
262
+ return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
263
+ }
264
+ function isWriteCapableTool(tool, config) {
265
+ return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
266
+ }
267
+ function requiresTrackerRefresh(state, tool, config) {
268
+ if (state.kind === "untracked" || tool === todoTrackerName) {
269
+ return false;
270
+ }
271
+ if (isWriteCapableTool(tool, config)) {
272
+ return state.requiresRefreshBeforeWrite;
273
+ }
274
+ if (isReadOnlyTool(tool, config)) {
275
+ return state.readToolsSinceRefresh >= readToolsBeforeRefresh;
276
+ }
277
+ return false;
278
+ }
279
+ function requiresTrackerRefreshBeforeMoreWork(state) {
280
+ return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
281
+ }
282
+ function trackerStateFromMessages(messages, config) {
283
+ let state = { kind: "untracked" };
284
+ for (const message of messages) {
285
+ if (message.type !== "assistant") {
286
+ continue;
287
+ }
288
+ for (const part of message.content) {
289
+ if (part.type !== "tool" || part.state.status !== "completed") {
290
+ continue;
291
+ }
292
+ if (part.name === todoTrackerName) {
293
+ state = refreshedTrackerState();
294
+ } else if (state.kind === "tracked") {
295
+ if (isWriteCapableTool(part.name, config)) {
296
+ state.requiresRefreshBeforeWrite = true;
297
+ } else if (isReadOnlyTool(part.name, config)) {
298
+ state.readToolsSinceRefresh += 1;
299
+ }
300
+ }
301
+ }
302
+ }
303
+ return state;
304
+ }
305
+
306
+ class TodoTrackerEnforcement {
307
+ context;
308
+ config;
309
+ states = new Map;
310
+ constructor(context, config) {
311
+ this.context = context;
312
+ this.config = config;
313
+ }
314
+ async stateFor(sessionID) {
315
+ const existing = this.states.get(sessionID);
316
+ if (existing !== undefined) {
317
+ return existing;
318
+ }
319
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
320
+ this.states.set(sessionID, state);
321
+ return state;
322
+ }
323
+ async markWriteRequiresRefresh(sessionID) {
324
+ const state = await this.stateFor(sessionID);
325
+ if (state.kind === "tracked") {
326
+ state.requiresRefreshBeforeWrite = true;
327
+ }
328
+ }
329
+ async register() {
330
+ let before;
331
+ let after;
332
+ let context;
333
+ try {
334
+ before = await this.context.tool.hook("execute.before", async (event) => {
335
+ const state = await this.stateFor(event.sessionID);
336
+ if (requiresTrackerRefresh(state, event.tool, this.config)) {
337
+ throw new Error(trackerRefreshMessage);
338
+ }
339
+ if (state.kind === "tracked") {
340
+ if (isWriteCapableTool(event.tool, this.config)) {
341
+ state.requiresRefreshBeforeWrite = true;
342
+ } else if (isReadOnlyTool(event.tool, this.config)) {
343
+ state.readToolsSinceRefresh += 1;
344
+ }
345
+ }
346
+ });
347
+ after = await this.context.tool.hook("execute.after", async (event) => {
348
+ if (event.status !== "completed") {
349
+ return;
350
+ }
351
+ if (event.tool === todoTrackerName) {
352
+ this.states.set(event.sessionID, refreshedTrackerState());
353
+ return;
354
+ }
355
+ const state = await this.stateFor(event.sessionID);
356
+ if (state.kind !== "tracked") {
357
+ return;
358
+ }
359
+ });
360
+ context = await this.context.session.hook("context", async (event) => {
361
+ const state = await this.stateFor(event.sessionID);
362
+ if (requiresTrackerRefreshBeforeMoreWork(state)) {
363
+ event.system.push({ type: "text", text: trackerRefreshMessage });
364
+ }
365
+ });
366
+ const beforeRegistration = before;
367
+ const afterRegistration = after;
368
+ const contextRegistration = context;
369
+ const controller = new AbortController;
370
+ const observer = this.observeSubagentCompletion(controller.signal);
371
+ return {
372
+ dispose: async () => {
373
+ controller.abort();
374
+ await Promise.all([
375
+ beforeRegistration.dispose(),
376
+ afterRegistration.dispose(),
377
+ contextRegistration.dispose(),
378
+ observer
379
+ ]);
380
+ }
381
+ };
382
+ } catch (error) {
383
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
384
+ throw error;
385
+ }
386
+ }
387
+ async observeSubagentCompletion(signal) {
388
+ try {
389
+ for await (const event of this.context.event.subscribe({ signal })) {
390
+ if (event.type !== "session.idle") {
391
+ continue;
392
+ }
393
+ const session = await this.context.session.get({ sessionID: event.data.sessionID });
394
+ if (session.parentID !== undefined) {
395
+ await this.markWriteRequiresRefresh(session.parentID);
396
+ }
397
+ }
398
+ } catch (error) {
399
+ if (!signal.aborted) {
400
+ console.error("Unable to watch subagent completion for todo enforcement.", error);
401
+ }
402
+ }
403
+ }
404
+ }
213
405
 
214
406
  class TodoTracker {
215
407
  static marker(status) {
@@ -224,11 +416,15 @@ class TodoTracker {
224
416
  return "[-]";
225
417
  }
226
418
  }
227
- static sidebar(messages) {
228
- return todosFromMessages(messages);
419
+ static sidebar(messages, completed = false) {
420
+ const todos = todosFromMessages(messages);
421
+ if (!completed) {
422
+ return todos;
423
+ }
424
+ return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
229
425
  }
230
- static register(context) {
231
- return context.tool.transform((tools) => {
426
+ static async register(context, config) {
427
+ const tool = await context.tool.transform((tools) => {
232
428
  tools.add({
233
429
  name: todoTrackerName,
234
430
  description: todoTrackerDescription,
@@ -242,6 +438,24 @@ class TodoTracker {
242
438
  }
243
439
  });
244
440
  });
441
+ if (!config.enforceTodoTracker) {
442
+ return tool;
443
+ }
444
+ try {
445
+ const enforcement = await new TodoTrackerEnforcement(context, config).register();
446
+ return {
447
+ dispose: async () => {
448
+ try {
449
+ await enforcement.dispose();
450
+ } finally {
451
+ await tool.dispose();
452
+ }
453
+ }
454
+ };
455
+ } catch (error) {
456
+ await tool.dispose();
457
+ throw error;
458
+ }
245
459
  }
246
460
  }
247
461
 
@@ -273,7 +487,7 @@ var server_default = Plugin.define({
273
487
  });
274
488
  let toolRegistration;
275
489
  try {
276
- toolRegistration = await TodoTracker.register(ctx);
490
+ toolRegistration = await TodoTracker.register(ctx, pluginConfig);
277
491
  } catch (error) {
278
492
  await commandRegistration.dispose();
279
493
  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,157 @@ function todosFromMessages(messages) {
1144
1036
  }
1145
1037
  return [];
1146
1038
  }
1039
+ var builtInReadOnlyTools = new Set(["glob", "grep", "lsp", "read", "skill", "webfetch", "websearch"]);
1040
+ var builtInWriteCapableTools = new Set(["edit", "patch", "shell", "subagent", "write"]);
1041
+ var compatibilityWriteCapableTools = new Set(["apply_patch", "bash"]);
1042
+ var readToolsBeforeRefresh = 10;
1043
+ function refreshedTrackerState() {
1044
+ return { kind: "tracked", readToolsSinceRefresh: 0, requiresRefreshBeforeWrite: false };
1045
+ }
1046
+ function isReadOnlyTool(tool, config) {
1047
+ return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
1048
+ }
1049
+ function isWriteCapableTool(tool, config) {
1050
+ return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
1051
+ }
1052
+ function requiresTrackerRefresh(state, tool, config) {
1053
+ if (state.kind === "untracked" || tool === todoTrackerName) {
1054
+ return false;
1055
+ }
1056
+ if (isWriteCapableTool(tool, config)) {
1057
+ return state.requiresRefreshBeforeWrite;
1058
+ }
1059
+ if (isReadOnlyTool(tool, config)) {
1060
+ return state.readToolsSinceRefresh >= readToolsBeforeRefresh;
1061
+ }
1062
+ return false;
1063
+ }
1064
+ function requiresTrackerRefreshBeforeMoreWork(state) {
1065
+ return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
1066
+ }
1067
+ function trackerStateFromMessages(messages, config) {
1068
+ let state = { kind: "untracked" };
1069
+ for (const message of messages) {
1070
+ if (message.type !== "assistant") {
1071
+ continue;
1072
+ }
1073
+ for (const part of message.content) {
1074
+ if (part.type !== "tool" || part.state.status !== "completed") {
1075
+ continue;
1076
+ }
1077
+ if (part.name === todoTrackerName) {
1078
+ state = refreshedTrackerState();
1079
+ } else if (state.kind === "tracked") {
1080
+ if (isWriteCapableTool(part.name, config)) {
1081
+ state.requiresRefreshBeforeWrite = true;
1082
+ } else if (isReadOnlyTool(part.name, config)) {
1083
+ state.readToolsSinceRefresh += 1;
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ return state;
1089
+ }
1090
+
1091
+ class TodoTrackerEnforcement {
1092
+ context;
1093
+ config;
1094
+ states = new Map;
1095
+ constructor(context, config) {
1096
+ this.context = context;
1097
+ this.config = config;
1098
+ }
1099
+ async stateFor(sessionID) {
1100
+ const existing = this.states.get(sessionID);
1101
+ if (existing !== undefined) {
1102
+ return existing;
1103
+ }
1104
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
1105
+ this.states.set(sessionID, state);
1106
+ return state;
1107
+ }
1108
+ async markWriteRequiresRefresh(sessionID) {
1109
+ const state = await this.stateFor(sessionID);
1110
+ if (state.kind === "tracked") {
1111
+ state.requiresRefreshBeforeWrite = true;
1112
+ }
1113
+ }
1114
+ async register() {
1115
+ let before;
1116
+ let after;
1117
+ let context;
1118
+ try {
1119
+ before = await this.context.tool.hook("execute.before", async (event) => {
1120
+ const state = await this.stateFor(event.sessionID);
1121
+ if (requiresTrackerRefresh(state, event.tool, this.config)) {
1122
+ throw new Error(trackerRefreshMessage);
1123
+ }
1124
+ if (state.kind === "tracked") {
1125
+ if (isWriteCapableTool(event.tool, this.config)) {
1126
+ state.requiresRefreshBeforeWrite = true;
1127
+ } else if (isReadOnlyTool(event.tool, this.config)) {
1128
+ state.readToolsSinceRefresh += 1;
1129
+ }
1130
+ }
1131
+ });
1132
+ after = await this.context.tool.hook("execute.after", async (event) => {
1133
+ if (event.status !== "completed") {
1134
+ return;
1135
+ }
1136
+ if (event.tool === todoTrackerName) {
1137
+ this.states.set(event.sessionID, refreshedTrackerState());
1138
+ return;
1139
+ }
1140
+ const state = await this.stateFor(event.sessionID);
1141
+ if (state.kind !== "tracked") {
1142
+ return;
1143
+ }
1144
+ });
1145
+ context = await this.context.session.hook("context", async (event) => {
1146
+ const state = await this.stateFor(event.sessionID);
1147
+ if (requiresTrackerRefreshBeforeMoreWork(state)) {
1148
+ event.system.push({ type: "text", text: trackerRefreshMessage });
1149
+ }
1150
+ });
1151
+ const beforeRegistration = before;
1152
+ const afterRegistration = after;
1153
+ const contextRegistration = context;
1154
+ const controller = new AbortController;
1155
+ const observer = this.observeSubagentCompletion(controller.signal);
1156
+ return {
1157
+ dispose: async () => {
1158
+ controller.abort();
1159
+ await Promise.all([
1160
+ beforeRegistration.dispose(),
1161
+ afterRegistration.dispose(),
1162
+ contextRegistration.dispose(),
1163
+ observer
1164
+ ]);
1165
+ }
1166
+ };
1167
+ } catch (error) {
1168
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
1169
+ throw error;
1170
+ }
1171
+ }
1172
+ async observeSubagentCompletion(signal) {
1173
+ try {
1174
+ for await (const event of this.context.event.subscribe({ signal })) {
1175
+ if (event.type !== "session.idle") {
1176
+ continue;
1177
+ }
1178
+ const session = await this.context.session.get({ sessionID: event.data.sessionID });
1179
+ if (session.parentID !== undefined) {
1180
+ await this.markWriteRequiresRefresh(session.parentID);
1181
+ }
1182
+ }
1183
+ } catch (error) {
1184
+ if (!signal.aborted) {
1185
+ console.error("Unable to watch subagent completion for todo enforcement.", error);
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1147
1190
 
1148
1191
  class TodoTracker {
1149
1192
  static marker(status) {
@@ -1158,11 +1201,15 @@ class TodoTracker {
1158
1201
  return "[-]";
1159
1202
  }
1160
1203
  }
1161
- static sidebar(messages) {
1162
- return todosFromMessages(messages);
1204
+ static sidebar(messages, completed = false) {
1205
+ const todos = todosFromMessages(messages);
1206
+ if (!completed) {
1207
+ return todos;
1208
+ }
1209
+ return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
1163
1210
  }
1164
- static register(context) {
1165
- return context.tool.transform((tools) => {
1211
+ static async register(context, config) {
1212
+ const tool = await context.tool.transform((tools) => {
1166
1213
  tools.add({
1167
1214
  name: todoTrackerName,
1168
1215
  description: todoTrackerDescription,
@@ -1176,6 +1223,24 @@ class TodoTracker {
1176
1223
  }
1177
1224
  });
1178
1225
  });
1226
+ if (!config.enforceTodoTracker) {
1227
+ return tool;
1228
+ }
1229
+ try {
1230
+ const enforcement = await new TodoTrackerEnforcement(context, config).register();
1231
+ return {
1232
+ dispose: async () => {
1233
+ try {
1234
+ await enforcement.dispose();
1235
+ } finally {
1236
+ await tool.dispose();
1237
+ }
1238
+ }
1239
+ };
1240
+ } catch (error) {
1241
+ await tool.dispose();
1242
+ throw error;
1243
+ }
1179
1244
  }
1180
1245
  }
1181
1246
 
@@ -1186,28 +1251,52 @@ function PickerCommandLayer(props) {
1186
1251
  return /* @__PURE__ */ jsx(Fragment, {});
1187
1252
  }
1188
1253
  function TodoSidebar(props) {
1189
- const todos = createMemo(() => TodoTracker.sidebar(props.context.data.session.message.list(props.sessionID)));
1254
+ const todos = () => TodoTracker.sidebar(props.context.data.session.message.list(props.sessionID), props.context.data.session.status(props.sessionID) === "idle");
1190
1255
  const color = (status) => status === "in_progress" ? props.context.theme.text.feedback.warning.base : props.context.theme.text.muted;
1256
+ const line = (todo) => `${TodoTracker.marker(todo.status)} (${todo.priority}) ${todo.content}`;
1257
+ const plainContent = (items) => items.map(line).join(`
1258
+ `);
1259
+ const content = (items) => new StyledText(items.flatMap((todo, index) => [
1260
+ fg(color(todo.status))(`${line(todo)}${index === items.length - 1 ? "" : `
1261
+ `}`)
1262
+ ]));
1263
+ let heading;
1264
+ let list;
1265
+ const render = () => {
1266
+ const items = todos();
1267
+ if (heading !== undefined) {
1268
+ heading.content = items.length > 0 ? "Todos" : "";
1269
+ }
1270
+ if (list !== undefined) {
1271
+ list.content = content(items);
1272
+ }
1273
+ };
1274
+ const initialTodos = todos();
1191
1275
  onMount(() => {
1276
+ render();
1277
+ const unlisten = props.context.data.listen?.(() => {
1278
+ render();
1279
+ });
1280
+ onCleanup(() => unlisten?.());
1192
1281
  props.context.data.session.message.sync(props.sessionID).catch(() => {});
1193
1282
  });
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
- })
1283
+ return /* @__PURE__ */ jsxs("box", {
1284
+ flexDirection: "column",
1285
+ children: [
1286
+ /* @__PURE__ */ jsx("text", {
1287
+ ref: (node) => {
1288
+ heading = node;
1289
+ },
1290
+ children: initialTodos.length > 0 ? "Todos" : ""
1291
+ }),
1292
+ /* @__PURE__ */ jsx("text", {
1293
+ ref: (node) => {
1294
+ list = node;
1295
+ },
1296
+ fg: props.context.theme.text.muted,
1297
+ content: plainContent(initialTodos)
1298
+ })
1299
+ ]
1211
1300
  });
1212
1301
  }
1213
1302
  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.5",
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",