@encoredigitalgroup/opencode 0.2.3 → 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 +36 -8
- package/dist/config/ConfigLoader.d.ts +2 -0
- package/dist/config/ConfigTypes.d.ts +6 -0
- package/dist/lib/TodoTracker.d.ts +54 -0
- package/dist/lib/TodoTracker.test.d.ts +1 -0
- package/dist/server/index.js +348 -4
- package/dist/tui/index.js +998 -4
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`@encoredigitalgroup/opencode` adds file pickers for OpenCode planning commands.
|
|
4
4
|
|
|
5
|
-
The TUI provides `/plan-review`, `/plan-resync`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and
|
|
6
|
-
selected plan into the prompt. The server entry attaches the matching installed skill to the prompt.
|
|
5
|
+
The TUI provides `/plan-review`, `/plan-resync`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and
|
|
6
|
+
inserts the selected plan into the prompt. The server entry attaches the matching installed skill to the prompt.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -24,15 +24,17 @@ OpenCode v2 loads this package from the `plugins` array. Add an object entry wit
|
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugins` array, so you do not need to install this
|
|
28
|
-
with Bun or npm yourself. This package supports OpenCode v2 only; replace the legacy `plugin` configuration entry when migrating from OpenCode v1.
|
|
27
|
+
Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugins` array, so you do not need to install this
|
|
28
|
+
package with Bun or npm yourself. This package supports OpenCode v2 only; replace the legacy `plugin` configuration entry when migrating from OpenCode v1.
|
|
29
29
|
|
|
30
30
|
The plugin resolves the mapped skills through OpenCode, so those skills can live in any location that OpenCode discovers.
|
|
31
31
|
|
|
32
32
|
## Configuration
|
|
33
33
|
|
|
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
|
-
supported command's installed skill name.
|
|
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. `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,17 +44,43 @@ supported command's installed skill name. Unspecified values use the defaults: `
|
|
|
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
|
]
|
|
52
57
|
}
|
|
53
58
|
```
|
|
54
59
|
|
|
55
|
-
Launching a picker from an existing session submits to that session. Launching one from the home screen creates and displays a new session before submitting the
|
|
60
|
+
Launching a picker from an existing session submits to that session. Launching one from the home screen creates and displays a new session before submitting the
|
|
61
|
+
selected plan.
|
|
62
|
+
|
|
63
|
+
## Todo tracker
|
|
64
|
+
|
|
65
|
+
The plugin registers the direct `todotracker` tool. Each call replaces the current session's complete ordered todo list. Pass an empty `todos` array to clear the
|
|
66
|
+
list.
|
|
67
|
+
Each todo requires:
|
|
68
|
+
|
|
69
|
+
- `content`: a non-empty string containing at least one non-whitespace character.
|
|
70
|
+
- `status`: `pending`, `in_progress`, `completed`, or `cancelled`.
|
|
71
|
+
- `priority`: `high`, `medium`, or `low`.
|
|
72
|
+
|
|
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
|
|
74
|
+
message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
|
|
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.
|
|
56
84
|
|
|
57
85
|
## Development
|
|
58
86
|
|
|
@@ -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 {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SessionMessageInfo } from "@opencode/client";
|
|
2
|
+
import type { Plugin as ServerPlugin } from "@opencode/plugin";
|
|
3
|
+
import type { PluginConfig } from "../config";
|
|
4
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
|
5
|
+
export type TodoPriority = "high" | "medium" | "low";
|
|
6
|
+
export type Todo = {
|
|
7
|
+
content: string;
|
|
8
|
+
status: TodoStatus;
|
|
9
|
+
priority: TodoPriority;
|
|
10
|
+
};
|
|
11
|
+
export type TodoTrackerInput = {
|
|
12
|
+
todos: Todo[];
|
|
13
|
+
};
|
|
14
|
+
export type TodoTrackerResultMetadata = {
|
|
15
|
+
todos: Todo[];
|
|
16
|
+
};
|
|
17
|
+
export declare const todoTrackerInput: {
|
|
18
|
+
readonly type: "object";
|
|
19
|
+
readonly additionalProperties: false;
|
|
20
|
+
readonly required: readonly ["todos"];
|
|
21
|
+
readonly properties: {
|
|
22
|
+
readonly todos: {
|
|
23
|
+
readonly type: "array";
|
|
24
|
+
readonly items: {
|
|
25
|
+
readonly type: "object";
|
|
26
|
+
readonly additionalProperties: false;
|
|
27
|
+
readonly required: readonly ["content", "status", "priority"];
|
|
28
|
+
readonly properties: {
|
|
29
|
+
readonly content: {
|
|
30
|
+
readonly type: "string";
|
|
31
|
+
readonly minLength: 1;
|
|
32
|
+
readonly pattern: "\\S";
|
|
33
|
+
};
|
|
34
|
+
readonly status: {
|
|
35
|
+
readonly enum: readonly ["pending", "in_progress", "completed", "cancelled"];
|
|
36
|
+
};
|
|
37
|
+
readonly priority: {
|
|
38
|
+
readonly enum: readonly ["high", "medium", "low"];
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export declare const todoTrackerName = "todotracker";
|
|
46
|
+
export declare const todoTrackerDescription: string;
|
|
47
|
+
export declare function isTodoTrackerInput(value: unknown): value is TodoTrackerInput;
|
|
48
|
+
export declare function isTodoResultMetadata(value: unknown): value is TodoTrackerResultMetadata;
|
|
49
|
+
export declare function todosFromMessages(messages: readonly SessionMessageInfo[]): readonly Todo[];
|
|
50
|
+
export declare class TodoTracker {
|
|
51
|
+
static marker(status: TodoStatus): string;
|
|
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>;
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server/index.js
CHANGED
|
@@ -15,8 +15,11 @@ class ConfigDefaults {
|
|
|
15
15
|
}
|
|
16
16
|
static getDefaultConfig() {
|
|
17
17
|
return {
|
|
18
|
+
enforceTodoTracker: true,
|
|
18
19
|
planDirectory: "docs/_planning",
|
|
19
|
-
|
|
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
|
-
...
|
|
144
|
+
...readOnlyTools === undefined ? {} : { readOnlyTools },
|
|
145
|
+
...skillMappings === undefined ? {} : { skillMappings },
|
|
146
|
+
...writeCapableTools === undefined ? {} : { writeCapableTools }
|
|
107
147
|
};
|
|
108
148
|
}
|
|
109
149
|
static loadConfig(options) {
|
|
@@ -128,6 +168,297 @@ class SkillCommands {
|
|
|
128
168
|
}
|
|
129
169
|
}
|
|
130
170
|
|
|
171
|
+
// src/lib/TodoTracker.ts
|
|
172
|
+
var todoTrackerInput = {
|
|
173
|
+
type: "object",
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
required: ["todos"],
|
|
176
|
+
properties: {
|
|
177
|
+
todos: {
|
|
178
|
+
type: "array",
|
|
179
|
+
items: {
|
|
180
|
+
type: "object",
|
|
181
|
+
additionalProperties: false,
|
|
182
|
+
required: ["content", "status", "priority"],
|
|
183
|
+
properties: {
|
|
184
|
+
content: { type: "string", minLength: 1, pattern: "\\S" },
|
|
185
|
+
status: { enum: ["pending", "in_progress", "completed", "cancelled"] },
|
|
186
|
+
priority: { enum: ["high", "medium", "low"] }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
var todoTrackerName = "todotracker";
|
|
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.";
|
|
195
|
+
var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
|
|
196
|
+
var todoPriorities = ["high", "medium", "low"];
|
|
197
|
+
function isRecord(value) {
|
|
198
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
199
|
+
}
|
|
200
|
+
function hasOnlyKeys(value, keys) {
|
|
201
|
+
const expected = new Set(keys);
|
|
202
|
+
return Object.keys(value).length === keys.length && Object.keys(value).every((key) => expected.has(key));
|
|
203
|
+
}
|
|
204
|
+
function isTodoStatus(value) {
|
|
205
|
+
return typeof value === "string" && todoStatuses.includes(value);
|
|
206
|
+
}
|
|
207
|
+
function isTodoPriority(value) {
|
|
208
|
+
return typeof value === "string" && todoPriorities.includes(value);
|
|
209
|
+
}
|
|
210
|
+
function isTodo(value) {
|
|
211
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["content", "status", "priority"])) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return typeof value.content === "string" && value.content.length >= 1 && /\S/.test(value.content) && isTodoStatus(value.status) && isTodoPriority(value.priority);
|
|
215
|
+
}
|
|
216
|
+
function isTodoList(value) {
|
|
217
|
+
return Array.isArray(value) && value.every(isTodo);
|
|
218
|
+
}
|
|
219
|
+
function isTodoTrackerInput(value) {
|
|
220
|
+
return isRecord(value) && hasOnlyKeys(value, ["todos"]) && isTodoList(value.todos);
|
|
221
|
+
}
|
|
222
|
+
function isTodoResultMetadata(value) {
|
|
223
|
+
return isTodoTrackerInput(value);
|
|
224
|
+
}
|
|
225
|
+
function todosFromToolResult(metadata, content) {
|
|
226
|
+
if (isTodoResultMetadata(metadata)) {
|
|
227
|
+
return metadata.todos;
|
|
228
|
+
}
|
|
229
|
+
for (const item of content) {
|
|
230
|
+
if (item.type !== "text" || item.text === undefined) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const todos = JSON.parse(item.text);
|
|
235
|
+
if (isTodoList(todos)) {
|
|
236
|
+
return todos;
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
function todosFromMessages(messages) {
|
|
243
|
+
for (const message of messages.toReversed()) {
|
|
244
|
+
if (message.type !== "assistant")
|
|
245
|
+
continue;
|
|
246
|
+
for (const part of message.content.toReversed()) {
|
|
247
|
+
if (part.type === "tool" && part.name === todoTrackerName && part.state.status === "completed") {
|
|
248
|
+
return todosFromToolResult(part.state.metadata, part.state.content);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return [];
|
|
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
|
+
}
|
|
405
|
+
|
|
406
|
+
class TodoTracker {
|
|
407
|
+
static marker(status) {
|
|
408
|
+
switch (status) {
|
|
409
|
+
case "pending":
|
|
410
|
+
return "[ ]";
|
|
411
|
+
case "in_progress":
|
|
412
|
+
return "[~]";
|
|
413
|
+
case "completed":
|
|
414
|
+
return "[x]";
|
|
415
|
+
case "cancelled":
|
|
416
|
+
return "[-]";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
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);
|
|
425
|
+
}
|
|
426
|
+
static async register(context, config) {
|
|
427
|
+
const tool = await context.tool.transform((tools) => {
|
|
428
|
+
tools.add({
|
|
429
|
+
name: todoTrackerName,
|
|
430
|
+
description: todoTrackerDescription,
|
|
431
|
+
input: todoTrackerInput,
|
|
432
|
+
options: { codemode: false },
|
|
433
|
+
async execute(input) {
|
|
434
|
+
if (!isTodoTrackerInput(input)) {
|
|
435
|
+
throw new Error("Invalid todotracker input.");
|
|
436
|
+
}
|
|
437
|
+
return { content: JSON.stringify(input.todos), metadata: { todos: input.todos } };
|
|
438
|
+
}
|
|
439
|
+
});
|
|
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
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
131
462
|
// src/server/index.ts
|
|
132
463
|
var server_default = Plugin.define({
|
|
133
464
|
id: "@encoredigitalgroup/opencode",
|
|
@@ -135,7 +466,7 @@ var server_default = Plugin.define({
|
|
|
135
466
|
const pluginConfig = ConfigLoader.loadConfig(ctx.options);
|
|
136
467
|
const command = "feature-planning";
|
|
137
468
|
const route = SkillCommands.getCommands(pluginConfig)[command];
|
|
138
|
-
const
|
|
469
|
+
const commandRegistration = await ctx.command.transform((commands) => {
|
|
139
470
|
commands.add({
|
|
140
471
|
name: command,
|
|
141
472
|
description: route.description,
|
|
@@ -154,7 +485,20 @@ var server_default = Plugin.define({
|
|
|
154
485
|
}
|
|
155
486
|
});
|
|
156
487
|
});
|
|
157
|
-
|
|
488
|
+
let toolRegistration;
|
|
489
|
+
try {
|
|
490
|
+
toolRegistration = await TodoTracker.register(ctx, pluginConfig);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await commandRegistration.dispose();
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
return async () => {
|
|
496
|
+
try {
|
|
497
|
+
await toolRegistration.dispose();
|
|
498
|
+
} finally {
|
|
499
|
+
await commandRegistration.dispose();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
158
502
|
}
|
|
159
503
|
});
|
|
160
504
|
export {
|