@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 +8 -2
- package/dist/lib/TodoTracker.d.ts +3 -1
- package/dist/server/index.js +56 -30
- package/dist/tui/index.js +56 -30
- package/package.json +1 -1
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
|
-
|
|
74
|
-
|
|
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<
|
|
53
|
+
static register(context: ServerPlugin.Context, config: PluginConfig): Promise<{
|
|
54
|
+
dispose: () => Promise<void>;
|
|
55
|
+
}>;
|
|
54
56
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
229
|
+
function parseTodoList(metadata, content) {
|
|
226
230
|
if (isTodoResultMetadata(metadata)) {
|
|
227
|
-
return metadata.todos;
|
|
231
|
+
return { valid: true, todos: metadata.todos };
|
|
228
232
|
}
|
|
229
|
-
|
|
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 =
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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
|
|
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
|
|
338
|
-
afterRegistration
|
|
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
|
-
|
|
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
|
|
1014
|
+
function parseTodoList(metadata, content) {
|
|
1011
1015
|
if (isTodoResultMetadata(metadata)) {
|
|
1012
|
-
return metadata.todos;
|
|
1016
|
+
return { valid: true, todos: metadata.todos };
|
|
1013
1017
|
}
|
|
1014
|
-
|
|
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 =
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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.
|
|
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
|
|
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
|
|
1123
|
-
afterRegistration
|
|
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
|
-
|
|
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 {
|