@encoredigitalgroup/opencode 0.2.5 → 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 +21 -20
- package/dist/config/ConfigTypes.d.ts +4 -0
- package/dist/lib/TodoTracker.d.ts +3 -1
- package/dist/server/index.js +72 -83
- package/dist/tui/index.js +72 -83
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,9 +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. `enforceTodoTracker` defaults to `true`; set it to `false` to disable tracker-refresh enforcement.
|
|
36
|
-
|
|
37
|
-
and no custom tool classifications.
|
|
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.
|
|
38
37
|
|
|
39
38
|
```json
|
|
40
39
|
{
|
|
@@ -42,16 +41,14 @@ and no custom tool classifications.
|
|
|
42
41
|
"plugins": [
|
|
43
42
|
{
|
|
44
43
|
"package": "@encoredigitalgroup/opencode",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
"writeCapableTools": ["deploy"]
|
|
54
|
-
}
|
|
44
|
+
"options": {
|
|
45
|
+
"planDirectory": "plans",
|
|
46
|
+
"enforceTodoTracker": false,
|
|
47
|
+
"skillMappings": {
|
|
48
|
+
"plan-review": "custom-plan-review",
|
|
49
|
+
"plan-execute": "custom-plan-execute"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
55
52
|
}
|
|
56
53
|
]
|
|
57
54
|
}
|
|
@@ -70,17 +67,21 @@ Each todo requires:
|
|
|
70
67
|
- `status`: `pending`, `in_progress`, `completed`, or `cancelled`.
|
|
71
68
|
- `priority`: `high`, `medium`, or `low`.
|
|
72
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
|
+
|
|
73
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
|
|
74
75
|
message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
|
|
75
76
|
|
|
76
|
-
After a session's first successful `todotracker` call, the plugin
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
disabled for all sessions with `"enforceTodoTracker": false`.
|
|
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
|
|
81
|
+
for sessions that have never used `todotracker`, and can be disabled for all sessions with `"enforceTodoTracker": false`.
|
|
82
82
|
|
|
83
|
-
When a session is idle, the sidebar renders
|
|
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
|
|
84
|
+
when work resumes.
|
|
84
85
|
|
|
85
86
|
## Development
|
|
86
87
|
|
|
@@ -4,15 +4,19 @@ export type SkillMappings = Record<SkillCommand, string>;
|
|
|
4
4
|
export type PluginConfig = {
|
|
5
5
|
enforceTodoTracker: boolean;
|
|
6
6
|
planDirectory: string;
|
|
7
|
+
/** @deprecated Tool classifications no longer affect todo tracker enforcement. */
|
|
7
8
|
readOnlyTools: string[];
|
|
8
9
|
skillMappings: SkillMappings;
|
|
10
|
+
/** @deprecated Tool classifications no longer affect todo tracker enforcement. */
|
|
9
11
|
writeCapableTools: string[];
|
|
10
12
|
};
|
|
11
13
|
export type UserPluginConfig = {
|
|
12
14
|
enforceTodoTracker?: boolean;
|
|
13
15
|
planDirectory?: string;
|
|
16
|
+
/** @deprecated Tool classifications no longer affect todo tracker enforcement. */
|
|
14
17
|
readOnlyTools?: string[];
|
|
15
18
|
skillMappings?: Partial<SkillMappings>;
|
|
19
|
+
/** @deprecated Tool classifications no longer affect todo tracker enforcement. */
|
|
16
20
|
writeCapableTools?: string[];
|
|
17
21
|
};
|
|
18
22
|
/** Configuration type utilities. */
|
|
@@ -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()) {
|
|
@@ -251,35 +267,17 @@ function todosFromMessages(messages) {
|
|
|
251
267
|
}
|
|
252
268
|
return [];
|
|
253
269
|
}
|
|
254
|
-
var
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
var readToolsBeforeRefresh = 10;
|
|
258
|
-
function refreshedTrackerState() {
|
|
259
|
-
return { kind: "tracked", readToolsSinceRefresh: 0, requiresRefreshBeforeWrite: false };
|
|
270
|
+
var trackerRefreshIntervalMs = 60000;
|
|
271
|
+
function refreshedTrackerState(refreshedAt = Date.now()) {
|
|
272
|
+
return { kind: "tracked", refreshedAt, refreshRequired: false };
|
|
260
273
|
}
|
|
261
|
-
function
|
|
262
|
-
return
|
|
274
|
+
function trackerRefreshDue(state, now = Date.now()) {
|
|
275
|
+
return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
|
|
263
276
|
}
|
|
264
|
-
function
|
|
265
|
-
return
|
|
277
|
+
function requiresTrackerRefresh(state, tool, now = Date.now()) {
|
|
278
|
+
return tool !== todoTrackerName && trackerRefreshDue(state, now);
|
|
266
279
|
}
|
|
267
|
-
function
|
|
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) {
|
|
280
|
+
function trackerStateFromMessages(messages) {
|
|
283
281
|
let state = { kind: "untracked" };
|
|
284
282
|
for (const message of messages) {
|
|
285
283
|
if (message.type !== "assistant") {
|
|
@@ -290,13 +288,7 @@ function trackerStateFromMessages(messages, config) {
|
|
|
290
288
|
continue;
|
|
291
289
|
}
|
|
292
290
|
if (part.name === todoTrackerName) {
|
|
293
|
-
state =
|
|
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
|
-
}
|
|
291
|
+
state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
|
|
300
292
|
}
|
|
301
293
|
}
|
|
302
294
|
}
|
|
@@ -305,82 +297,81 @@ function trackerStateFromMessages(messages, config) {
|
|
|
305
297
|
|
|
306
298
|
class TodoTrackerEnforcement {
|
|
307
299
|
context;
|
|
308
|
-
config;
|
|
309
300
|
states = new Map;
|
|
310
|
-
|
|
301
|
+
instructedSessionIDs = new Set;
|
|
302
|
+
constructor(context) {
|
|
311
303
|
this.context = context;
|
|
312
|
-
this.config = config;
|
|
313
304
|
}
|
|
314
305
|
async stateFor(sessionID) {
|
|
315
306
|
const existing = this.states.get(sessionID);
|
|
316
307
|
if (existing !== undefined) {
|
|
317
308
|
return existing;
|
|
318
309
|
}
|
|
319
|
-
const state = trackerStateFromMessages(await this.context.session.context({ sessionID })
|
|
310
|
+
const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
|
|
320
311
|
this.states.set(sessionID, state);
|
|
321
312
|
return state;
|
|
322
313
|
}
|
|
323
|
-
async
|
|
314
|
+
async requireTrackerRefresh(sessionID) {
|
|
324
315
|
const state = await this.stateFor(sessionID);
|
|
325
316
|
if (state.kind === "tracked") {
|
|
326
|
-
state.
|
|
317
|
+
state.refreshRequired = true;
|
|
327
318
|
}
|
|
328
319
|
}
|
|
329
|
-
async register() {
|
|
320
|
+
async register(enforceRefresh) {
|
|
330
321
|
let before;
|
|
331
322
|
let after;
|
|
332
323
|
let context;
|
|
324
|
+
let compaction;
|
|
325
|
+
let observer;
|
|
326
|
+
const controller = new AbortController;
|
|
333
327
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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;
|
|
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 });
|
|
354
332
|
}
|
|
355
|
-
|
|
356
|
-
if (state.kind !== "tracked") {
|
|
333
|
+
if (!enforceRefresh) {
|
|
357
334
|
return;
|
|
358
335
|
}
|
|
359
|
-
|
|
360
|
-
context = await this.context.session.hook("context", async (event) => {
|
|
361
|
-
const state = await this.stateFor(event.sessionID);
|
|
362
|
-
if (requiresTrackerRefreshBeforeMoreWork(state)) {
|
|
336
|
+
if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
|
|
363
337
|
event.system.push({ type: "text", text: trackerRefreshMessage });
|
|
364
338
|
}
|
|
365
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
|
+
}
|
|
366
356
|
const beforeRegistration = before;
|
|
367
357
|
const afterRegistration = after;
|
|
368
358
|
const contextRegistration = context;
|
|
369
|
-
const
|
|
370
|
-
const observer = this.observeSubagentCompletion(controller.signal);
|
|
359
|
+
const compactionRegistration = compaction;
|
|
371
360
|
return {
|
|
372
361
|
dispose: async () => {
|
|
373
362
|
controller.abort();
|
|
374
363
|
await Promise.all([
|
|
375
|
-
beforeRegistration
|
|
376
|
-
afterRegistration
|
|
364
|
+
beforeRegistration?.dispose(),
|
|
365
|
+
afterRegistration?.dispose(),
|
|
377
366
|
contextRegistration.dispose(),
|
|
367
|
+
compactionRegistration.dispose(),
|
|
378
368
|
observer
|
|
379
369
|
]);
|
|
380
370
|
}
|
|
381
371
|
};
|
|
382
372
|
} catch (error) {
|
|
383
|
-
|
|
373
|
+
controller.abort();
|
|
374
|
+
await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
|
|
384
375
|
throw error;
|
|
385
376
|
}
|
|
386
377
|
}
|
|
@@ -392,7 +383,7 @@ class TodoTrackerEnforcement {
|
|
|
392
383
|
}
|
|
393
384
|
const session = await this.context.session.get({ sessionID: event.data.sessionID });
|
|
394
385
|
if (session.parentID !== undefined) {
|
|
395
|
-
await this.
|
|
386
|
+
await this.requireTrackerRefresh(session.parentID);
|
|
396
387
|
}
|
|
397
388
|
}
|
|
398
389
|
} catch (error) {
|
|
@@ -418,10 +409,11 @@ class TodoTracker {
|
|
|
418
409
|
}
|
|
419
410
|
static sidebar(messages, completed = false) {
|
|
420
411
|
const todos = todosFromMessages(messages);
|
|
421
|
-
|
|
412
|
+
const finalTodo = todos.at(-1);
|
|
413
|
+
if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
|
|
422
414
|
return todos;
|
|
423
415
|
}
|
|
424
|
-
return todos.
|
|
416
|
+
return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
|
|
425
417
|
}
|
|
426
418
|
static async register(context, config) {
|
|
427
419
|
const tool = await context.tool.transform((tools) => {
|
|
@@ -438,11 +430,8 @@ class TodoTracker {
|
|
|
438
430
|
}
|
|
439
431
|
});
|
|
440
432
|
});
|
|
441
|
-
if (!config.enforceTodoTracker) {
|
|
442
|
-
return tool;
|
|
443
|
-
}
|
|
444
433
|
try {
|
|
445
|
-
const enforcement = await new TodoTrackerEnforcement(context
|
|
434
|
+
const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
|
|
446
435
|
return {
|
|
447
436
|
dispose: async () => {
|
|
448
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()) {
|
|
@@ -1036,35 +1052,17 @@ function todosFromMessages(messages) {
|
|
|
1036
1052
|
}
|
|
1037
1053
|
return [];
|
|
1038
1054
|
}
|
|
1039
|
-
var
|
|
1040
|
-
|
|
1041
|
-
|
|
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);
|
|
1055
|
+
var trackerRefreshIntervalMs = 60000;
|
|
1056
|
+
function refreshedTrackerState(refreshedAt = Date.now()) {
|
|
1057
|
+
return { kind: "tracked", refreshedAt, refreshRequired: false };
|
|
1048
1058
|
}
|
|
1049
|
-
function
|
|
1050
|
-
return
|
|
1059
|
+
function trackerRefreshDue(state, now = Date.now()) {
|
|
1060
|
+
return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
|
|
1051
1061
|
}
|
|
1052
|
-
function requiresTrackerRefresh(state, tool,
|
|
1053
|
-
|
|
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;
|
|
1062
|
+
function requiresTrackerRefresh(state, tool, now = Date.now()) {
|
|
1063
|
+
return tool !== todoTrackerName && trackerRefreshDue(state, now);
|
|
1063
1064
|
}
|
|
1064
|
-
function
|
|
1065
|
-
return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
|
|
1066
|
-
}
|
|
1067
|
-
function trackerStateFromMessages(messages, config) {
|
|
1065
|
+
function trackerStateFromMessages(messages) {
|
|
1068
1066
|
let state = { kind: "untracked" };
|
|
1069
1067
|
for (const message of messages) {
|
|
1070
1068
|
if (message.type !== "assistant") {
|
|
@@ -1075,13 +1073,7 @@ function trackerStateFromMessages(messages, config) {
|
|
|
1075
1073
|
continue;
|
|
1076
1074
|
}
|
|
1077
1075
|
if (part.name === todoTrackerName) {
|
|
1078
|
-
state =
|
|
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
|
-
}
|
|
1076
|
+
state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
|
|
1085
1077
|
}
|
|
1086
1078
|
}
|
|
1087
1079
|
}
|
|
@@ -1090,82 +1082,81 @@ function trackerStateFromMessages(messages, config) {
|
|
|
1090
1082
|
|
|
1091
1083
|
class TodoTrackerEnforcement {
|
|
1092
1084
|
context;
|
|
1093
|
-
config;
|
|
1094
1085
|
states = new Map;
|
|
1095
|
-
|
|
1086
|
+
instructedSessionIDs = new Set;
|
|
1087
|
+
constructor(context) {
|
|
1096
1088
|
this.context = context;
|
|
1097
|
-
this.config = config;
|
|
1098
1089
|
}
|
|
1099
1090
|
async stateFor(sessionID) {
|
|
1100
1091
|
const existing = this.states.get(sessionID);
|
|
1101
1092
|
if (existing !== undefined) {
|
|
1102
1093
|
return existing;
|
|
1103
1094
|
}
|
|
1104
|
-
const state = trackerStateFromMessages(await this.context.session.context({ sessionID })
|
|
1095
|
+
const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
|
|
1105
1096
|
this.states.set(sessionID, state);
|
|
1106
1097
|
return state;
|
|
1107
1098
|
}
|
|
1108
|
-
async
|
|
1099
|
+
async requireTrackerRefresh(sessionID) {
|
|
1109
1100
|
const state = await this.stateFor(sessionID);
|
|
1110
1101
|
if (state.kind === "tracked") {
|
|
1111
|
-
state.
|
|
1102
|
+
state.refreshRequired = true;
|
|
1112
1103
|
}
|
|
1113
1104
|
}
|
|
1114
|
-
async register() {
|
|
1105
|
+
async register(enforceRefresh) {
|
|
1115
1106
|
let before;
|
|
1116
1107
|
let after;
|
|
1117
1108
|
let context;
|
|
1109
|
+
let compaction;
|
|
1110
|
+
let observer;
|
|
1111
|
+
const controller = new AbortController;
|
|
1118
1112
|
try {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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;
|
|
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 });
|
|
1139
1117
|
}
|
|
1140
|
-
|
|
1141
|
-
if (state.kind !== "tracked") {
|
|
1118
|
+
if (!enforceRefresh) {
|
|
1142
1119
|
return;
|
|
1143
1120
|
}
|
|
1144
|
-
|
|
1145
|
-
context = await this.context.session.hook("context", async (event) => {
|
|
1146
|
-
const state = await this.stateFor(event.sessionID);
|
|
1147
|
-
if (requiresTrackerRefreshBeforeMoreWork(state)) {
|
|
1121
|
+
if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
|
|
1148
1122
|
event.system.push({ type: "text", text: trackerRefreshMessage });
|
|
1149
1123
|
}
|
|
1150
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
|
+
}
|
|
1151
1141
|
const beforeRegistration = before;
|
|
1152
1142
|
const afterRegistration = after;
|
|
1153
1143
|
const contextRegistration = context;
|
|
1154
|
-
const
|
|
1155
|
-
const observer = this.observeSubagentCompletion(controller.signal);
|
|
1144
|
+
const compactionRegistration = compaction;
|
|
1156
1145
|
return {
|
|
1157
1146
|
dispose: async () => {
|
|
1158
1147
|
controller.abort();
|
|
1159
1148
|
await Promise.all([
|
|
1160
|
-
beforeRegistration
|
|
1161
|
-
afterRegistration
|
|
1149
|
+
beforeRegistration?.dispose(),
|
|
1150
|
+
afterRegistration?.dispose(),
|
|
1162
1151
|
contextRegistration.dispose(),
|
|
1152
|
+
compactionRegistration.dispose(),
|
|
1163
1153
|
observer
|
|
1164
1154
|
]);
|
|
1165
1155
|
}
|
|
1166
1156
|
};
|
|
1167
1157
|
} catch (error) {
|
|
1168
|
-
|
|
1158
|
+
controller.abort();
|
|
1159
|
+
await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
|
|
1169
1160
|
throw error;
|
|
1170
1161
|
}
|
|
1171
1162
|
}
|
|
@@ -1177,7 +1168,7 @@ class TodoTrackerEnforcement {
|
|
|
1177
1168
|
}
|
|
1178
1169
|
const session = await this.context.session.get({ sessionID: event.data.sessionID });
|
|
1179
1170
|
if (session.parentID !== undefined) {
|
|
1180
|
-
await this.
|
|
1171
|
+
await this.requireTrackerRefresh(session.parentID);
|
|
1181
1172
|
}
|
|
1182
1173
|
}
|
|
1183
1174
|
} catch (error) {
|
|
@@ -1203,10 +1194,11 @@ class TodoTracker {
|
|
|
1203
1194
|
}
|
|
1204
1195
|
static sidebar(messages, completed = false) {
|
|
1205
1196
|
const todos = todosFromMessages(messages);
|
|
1206
|
-
|
|
1197
|
+
const finalTodo = todos.at(-1);
|
|
1198
|
+
if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
|
|
1207
1199
|
return todos;
|
|
1208
1200
|
}
|
|
1209
|
-
return todos.
|
|
1201
|
+
return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
|
|
1210
1202
|
}
|
|
1211
1203
|
static async register(context, config) {
|
|
1212
1204
|
const tool = await context.tool.transform((tools) => {
|
|
@@ -1223,11 +1215,8 @@ class TodoTracker {
|
|
|
1223
1215
|
}
|
|
1224
1216
|
});
|
|
1225
1217
|
});
|
|
1226
|
-
if (!config.enforceTodoTracker) {
|
|
1227
|
-
return tool;
|
|
1228
|
-
}
|
|
1229
1218
|
try {
|
|
1230
|
-
const enforcement = await new TodoTrackerEnforcement(context
|
|
1219
|
+
const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
|
|
1231
1220
|
return {
|
|
1232
1221
|
dispose: async () => {
|
|
1233
1222
|
try {
|