@encoredigitalgroup/opencode 0.2.6 → 0.2.7

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
@@ -67,11 +67,17 @@ Each todo requires:
67
67
  - `status`: `pending`, `in_progress`, `completed`, or `cancelled`.
68
68
  - `priority`: `high`, `medium`, or `low`.
69
69
 
70
+ Each tracker call replaces the complete ordered list. Include discovered work and current statuses in every replacement. Mark a todo completed only after
71
+ verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates,
72
+ tool restrictions, or difficult work.
73
+
70
74
  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
71
75
  message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
72
76
 
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
77
+ Automatic refresh enforcement runs only while the latest valid list contains nonterminal work. After a session's first successful `todotracker` call, the plugin
78
+ requires another update at least once per minute while that work remains. When an update is due, it blocks every other tool until the tracker is updated. A valid
79
+ empty list or a list containing only `completed` and `cancelled` todos ends automatic refresh enforcement. A tracked subagent completing also requires its parent
80
+ session to update the tracker before continuing. Malformed tracker results remain enforcement-active because completion was not verified. Enforcement is disabled
75
81
  for sessions that have never used `todotracker`, and can be disabled for all sessions with `"enforceTodoTracker": false`.
76
82
 
77
83
  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
@@ -50,5 +50,7 @@ export declare function todosFromMessages(messages: readonly SessionMessageInfo[
50
50
  export declare class TodoTracker {
51
51
  static marker(status: TodoStatus): string;
52
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
+ static register(context: ServerPlugin.Context, config: PluginConfig): Promise<{
54
+ dispose: () => Promise<void>;
55
+ }>;
54
56
  }
@@ -192,6 +192,10 @@ var todoTrackerInput = {
192
192
  var todoTrackerName = "todotracker";
193
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
194
  var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
195
+ var todoTrackerContract = "Todotracker rules: Each call replaces the complete ordered todo list. Include discovered work and current statuses in every replacement. Mark a todo completed only after verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates, tool restrictions, or difficult work.";
196
+ var compactionTrackerContract = `Retain this TodoTracker contract verbatim in the continuation summary:
197
+
198
+ ${todoTrackerContract}`;
195
199
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
196
200
  var todoPriorities = ["high", "medium", "low"];
197
201
  function isRecord(value) {
@@ -222,22 +226,34 @@ function isTodoTrackerInput(value) {
222
226
  function isTodoResultMetadata(value) {
223
227
  return isTodoTrackerInput(value);
224
228
  }
225
- function todosFromToolResult(metadata, content) {
229
+ function parseTodoList(metadata, content) {
226
230
  if (isTodoResultMetadata(metadata)) {
227
- return metadata.todos;
231
+ return { valid: true, todos: metadata.todos };
228
232
  }
229
- for (const item of content) {
233
+ const contentItems = typeof content === "string" ? [{ type: "text", text: content }] : content;
234
+ for (const item of contentItems) {
230
235
  if (item.type !== "text" || item.text === undefined) {
231
236
  continue;
232
237
  }
233
238
  try {
234
239
  const todos = JSON.parse(item.text);
235
240
  if (isTodoList(todos)) {
236
- return todos;
241
+ return { valid: true, todos };
237
242
  }
238
243
  } catch {}
239
244
  }
240
- return [];
245
+ return { valid: false };
246
+ }
247
+ function todosFromToolResult(metadata, content) {
248
+ const parsed = parseTodoList(metadata, content);
249
+ return parsed.valid ? parsed.todos : [];
250
+ }
251
+ function isTerminalTodoList(todos) {
252
+ return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled");
253
+ }
254
+ function stateFromTodoResult(metadata, content, refreshedAt = Date.now()) {
255
+ const parsed = parseTodoList(metadata, content);
256
+ return parsed.valid && isTerminalTodoList(parsed.todos) ? { kind: "untracked" } : refreshedTrackerState(refreshedAt);
241
257
  }
242
258
  function todosFromMessages(messages) {
243
259
  for (const message of messages.toReversed()) {
@@ -272,7 +288,7 @@ function trackerStateFromMessages(messages) {
272
288
  continue;
273
289
  }
274
290
  if (part.name === todoTrackerName) {
275
- state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
291
+ state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
276
292
  }
277
293
  }
278
294
  }
@@ -282,6 +298,7 @@ function trackerStateFromMessages(messages) {
282
298
  class TodoTrackerEnforcement {
283
299
  context;
284
300
  states = new Map;
301
+ instructedSessionIDs = new Set;
285
302
  constructor(context) {
286
303
  this.context = context;
287
304
  }
@@ -300,49 +317,61 @@ class TodoTrackerEnforcement {
300
317
  state.refreshRequired = true;
301
318
  }
302
319
  }
303
- async register() {
320
+ async register(enforceRefresh) {
304
321
  let before;
305
322
  let after;
306
323
  let context;
324
+ let compaction;
325
+ let observer;
326
+ const controller = new AbortController;
307
327
  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);
328
+ context = await this.context.session.hook("context", async (event) => {
329
+ if (!this.instructedSessionIDs.has(event.sessionID)) {
330
+ this.instructedSessionIDs.add(event.sessionID);
331
+ event.system.push({ type: "text", text: todoTrackerContract });
312
332
  }
313
- });
314
- after = await this.context.tool.hook("execute.after", async (event) => {
315
- if (event.status !== "completed") {
333
+ if (!enforceRefresh) {
316
334
  return;
317
335
  }
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)) {
336
+ if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
325
337
  event.system.push({ type: "text", text: trackerRefreshMessage });
326
338
  }
327
339
  });
340
+ compaction = await this.context.session.hook("compaction", async (event) => {
341
+ event.system.push({ type: "text", text: compactionTrackerContract });
342
+ });
343
+ if (enforceRefresh) {
344
+ before = await this.context.tool.hook("execute.before", async (event) => {
345
+ if (requiresTrackerRefresh(await this.stateFor(event.sessionID), event.tool)) {
346
+ throw new Error(trackerRefreshMessage);
347
+ }
348
+ });
349
+ after = await this.context.tool.hook("execute.after", async (event) => {
350
+ if (event.status === "completed" && event.tool === todoTrackerName) {
351
+ this.states.set(event.sessionID, stateFromTodoResult(event.result.metadata, event.result.content ?? []));
352
+ }
353
+ });
354
+ observer = this.observeSubagentCompletion(controller.signal);
355
+ }
328
356
  const beforeRegistration = before;
329
357
  const afterRegistration = after;
330
358
  const contextRegistration = context;
331
- const controller = new AbortController;
332
- const observer = this.observeSubagentCompletion(controller.signal);
359
+ const compactionRegistration = compaction;
333
360
  return {
334
361
  dispose: async () => {
335
362
  controller.abort();
336
363
  await Promise.all([
337
- beforeRegistration.dispose(),
338
- afterRegistration.dispose(),
364
+ beforeRegistration?.dispose(),
365
+ afterRegistration?.dispose(),
339
366
  contextRegistration.dispose(),
367
+ compactionRegistration.dispose(),
340
368
  observer
341
369
  ]);
342
370
  }
343
371
  };
344
372
  } catch (error) {
345
- await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
373
+ controller.abort();
374
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
346
375
  throw error;
347
376
  }
348
377
  }
@@ -401,11 +430,8 @@ class TodoTracker {
401
430
  }
402
431
  });
403
432
  });
404
- if (!config.enforceTodoTracker) {
405
- return tool;
406
- }
407
433
  try {
408
- const enforcement = await new TodoTrackerEnforcement(context).register();
434
+ const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
409
435
  return {
410
436
  dispose: async () => {
411
437
  try {
package/dist/tui/index.js CHANGED
@@ -977,6 +977,10 @@ var todoTrackerInput = {
977
977
  var todoTrackerName = "todotracker";
978
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
979
  var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
980
+ var todoTrackerContract = "Todotracker rules: Each call replaces the complete ordered todo list. Include discovered work and current statuses in every replacement. Mark a todo completed only after verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates, tool restrictions, or difficult work.";
981
+ var compactionTrackerContract = `Retain this TodoTracker contract verbatim in the continuation summary:
982
+
983
+ ${todoTrackerContract}`;
980
984
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
981
985
  var todoPriorities = ["high", "medium", "low"];
982
986
  function isRecord(value) {
@@ -1007,22 +1011,34 @@ function isTodoTrackerInput(value) {
1007
1011
  function isTodoResultMetadata(value) {
1008
1012
  return isTodoTrackerInput(value);
1009
1013
  }
1010
- function todosFromToolResult(metadata, content) {
1014
+ function parseTodoList(metadata, content) {
1011
1015
  if (isTodoResultMetadata(metadata)) {
1012
- return metadata.todos;
1016
+ return { valid: true, todos: metadata.todos };
1013
1017
  }
1014
- for (const item of content) {
1018
+ const contentItems = typeof content === "string" ? [{ type: "text", text: content }] : content;
1019
+ for (const item of contentItems) {
1015
1020
  if (item.type !== "text" || item.text === undefined) {
1016
1021
  continue;
1017
1022
  }
1018
1023
  try {
1019
1024
  const todos = JSON.parse(item.text);
1020
1025
  if (isTodoList(todos)) {
1021
- return todos;
1026
+ return { valid: true, todos };
1022
1027
  }
1023
1028
  } catch {}
1024
1029
  }
1025
- return [];
1030
+ return { valid: false };
1031
+ }
1032
+ function todosFromToolResult(metadata, content) {
1033
+ const parsed = parseTodoList(metadata, content);
1034
+ return parsed.valid ? parsed.todos : [];
1035
+ }
1036
+ function isTerminalTodoList(todos) {
1037
+ return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled");
1038
+ }
1039
+ function stateFromTodoResult(metadata, content, refreshedAt = Date.now()) {
1040
+ const parsed = parseTodoList(metadata, content);
1041
+ return parsed.valid && isTerminalTodoList(parsed.todos) ? { kind: "untracked" } : refreshedTrackerState(refreshedAt);
1026
1042
  }
1027
1043
  function todosFromMessages(messages) {
1028
1044
  for (const message of messages.toReversed()) {
@@ -1057,7 +1073,7 @@ function trackerStateFromMessages(messages) {
1057
1073
  continue;
1058
1074
  }
1059
1075
  if (part.name === todoTrackerName) {
1060
- state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
1076
+ state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
1061
1077
  }
1062
1078
  }
1063
1079
  }
@@ -1067,6 +1083,7 @@ function trackerStateFromMessages(messages) {
1067
1083
  class TodoTrackerEnforcement {
1068
1084
  context;
1069
1085
  states = new Map;
1086
+ instructedSessionIDs = new Set;
1070
1087
  constructor(context) {
1071
1088
  this.context = context;
1072
1089
  }
@@ -1085,49 +1102,61 @@ class TodoTrackerEnforcement {
1085
1102
  state.refreshRequired = true;
1086
1103
  }
1087
1104
  }
1088
- async register() {
1105
+ async register(enforceRefresh) {
1089
1106
  let before;
1090
1107
  let after;
1091
1108
  let context;
1109
+ let compaction;
1110
+ let observer;
1111
+ const controller = new AbortController;
1092
1112
  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);
1113
+ context = await this.context.session.hook("context", async (event) => {
1114
+ if (!this.instructedSessionIDs.has(event.sessionID)) {
1115
+ this.instructedSessionIDs.add(event.sessionID);
1116
+ event.system.push({ type: "text", text: todoTrackerContract });
1097
1117
  }
1098
- });
1099
- after = await this.context.tool.hook("execute.after", async (event) => {
1100
- if (event.status !== "completed") {
1118
+ if (!enforceRefresh) {
1101
1119
  return;
1102
1120
  }
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)) {
1121
+ if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
1110
1122
  event.system.push({ type: "text", text: trackerRefreshMessage });
1111
1123
  }
1112
1124
  });
1125
+ compaction = await this.context.session.hook("compaction", async (event) => {
1126
+ event.system.push({ type: "text", text: compactionTrackerContract });
1127
+ });
1128
+ if (enforceRefresh) {
1129
+ before = await this.context.tool.hook("execute.before", async (event) => {
1130
+ if (requiresTrackerRefresh(await this.stateFor(event.sessionID), event.tool)) {
1131
+ throw new Error(trackerRefreshMessage);
1132
+ }
1133
+ });
1134
+ after = await this.context.tool.hook("execute.after", async (event) => {
1135
+ if (event.status === "completed" && event.tool === todoTrackerName) {
1136
+ this.states.set(event.sessionID, stateFromTodoResult(event.result.metadata, event.result.content ?? []));
1137
+ }
1138
+ });
1139
+ observer = this.observeSubagentCompletion(controller.signal);
1140
+ }
1113
1141
  const beforeRegistration = before;
1114
1142
  const afterRegistration = after;
1115
1143
  const contextRegistration = context;
1116
- const controller = new AbortController;
1117
- const observer = this.observeSubagentCompletion(controller.signal);
1144
+ const compactionRegistration = compaction;
1118
1145
  return {
1119
1146
  dispose: async () => {
1120
1147
  controller.abort();
1121
1148
  await Promise.all([
1122
- beforeRegistration.dispose(),
1123
- afterRegistration.dispose(),
1149
+ beforeRegistration?.dispose(),
1150
+ afterRegistration?.dispose(),
1124
1151
  contextRegistration.dispose(),
1152
+ compactionRegistration.dispose(),
1125
1153
  observer
1126
1154
  ]);
1127
1155
  }
1128
1156
  };
1129
1157
  } catch (error) {
1130
- await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
1158
+ controller.abort();
1159
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
1131
1160
  throw error;
1132
1161
  }
1133
1162
  }
@@ -1186,11 +1215,8 @@ class TodoTracker {
1186
1215
  }
1187
1216
  });
1188
1217
  });
1189
- if (!config.enforceTodoTracker) {
1190
- return tool;
1191
- }
1192
1218
  try {
1193
- const enforcement = await new TodoTrackerEnforcement(context).register();
1219
+ const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
1194
1220
  return {
1195
1221
  dispose: async () => {
1196
1222
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@encoredigitalgroup/opencode",
3
3
  "type": "module",
4
- "version": "0.2.6",
4
+ "version": "0.2.7",
5
5
  "description": "OpenCode file-picker commands for planning skills.",
6
6
  "publishConfig": {
7
7
  "access": "public"