opencode-todo-enforcer 0.1.0
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/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/index.d.mts +33 -0
- package/dist/index.mjs +733 -0
- package/dist/index.mjs.map +1 -0
- package/docs/parity-notes.md +24 -0
- package/index.d.ts +32 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# opencode-todo-enforcer
|
|
2
|
+
|
|
3
|
+
Standalone OpenCode plugin that enforces todo continuation when a session goes idle.
|
|
4
|
+
|
|
5
|
+
It is inspired by `oh-my-opencode`'s todo continuation enforcer, but packaged independently so you can use it in any OpenCode setup.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Listens for `session.idle`
|
|
10
|
+
- Checks for incomplete todos
|
|
11
|
+
- Applies safety guards (abort window, cooldown/backoff, skipped agents, stop state)
|
|
12
|
+
- Starts a countdown before injecting a continuation prompt
|
|
13
|
+
- Cancels continuation when user/tool/assistant activity resumes
|
|
14
|
+
- Supports per-session pause via `/stop-continuation`
|
|
15
|
+
- Includes debug tool `todo_enforcer_debug_ping` for runtime verification
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Add to OpenCode config:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"$schema": "https://opencode.ai/config.json",
|
|
24
|
+
"plugin": ["opencode-todo-enforcer"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For local development without publishing, load from your working directory:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"$schema": "https://opencode.ai/config.json",
|
|
33
|
+
"plugin": ["opencode-todo-enforcer@file:/absolute/path/to/opencode-todo-enforcer"]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Optional configuration
|
|
38
|
+
|
|
39
|
+
You can export a configured plugin factory:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createTodoEnforcerPlugin } from "opencode-todo-enforcer";
|
|
43
|
+
|
|
44
|
+
export default createTodoEnforcerPlugin({
|
|
45
|
+
countdownMs: 1500,
|
|
46
|
+
continuationCooldownMs: 7000,
|
|
47
|
+
skipAgents: ["compaction", "prometheus"],
|
|
48
|
+
stopCommand: "/stop-continuation",
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Defaults
|
|
53
|
+
|
|
54
|
+
- `countdownMs`: `2000`
|
|
55
|
+
- `continuationCooldownMs`: `5000` (exponential backoff using consecutive failures)
|
|
56
|
+
- `abortWindowMs`: `3000`
|
|
57
|
+
- `maxConsecutiveFailures`: `5`
|
|
58
|
+
- `skipAgents`: `prometheus`, `compaction`
|
|
59
|
+
|
|
60
|
+
### Test-only env override
|
|
61
|
+
|
|
62
|
+
- `OPENCODE_TODO_ENFORCER_STOP_COMMAND` lets you override the stop command string (useful for E2E harnesses).
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bun install
|
|
68
|
+
bun run lint
|
|
69
|
+
bun run typecheck
|
|
70
|
+
bun run test
|
|
71
|
+
bun run test:integration
|
|
72
|
+
bun run test:e2e
|
|
73
|
+
bun run check
|
|
74
|
+
bun run build
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
See `docs/parity-notes.md` for parity and intentional differences from upstream behavior.
|
|
78
|
+
|
|
79
|
+
## Testing strategy
|
|
80
|
+
|
|
81
|
+
- Unit tests in `test/` validate guards, stop-state behavior, and orchestrator event handling.
|
|
82
|
+
- Integration test (`bun run test:integration`) runs end-to-end hook flows against a mocked OpenCode runtime:
|
|
83
|
+
- idle continuation,
|
|
84
|
+
- cooldown behavior,
|
|
85
|
+
- stop/resume behavior,
|
|
86
|
+
- compaction-only skip behavior,
|
|
87
|
+
- completed todo skip behavior.
|
|
88
|
+
- Live CLI E2E (`bun run test:e2e`) runs real `opencode run` prompts and validates telemetry assertions.
|
|
89
|
+
- npm-mode E2E (`bun run test:e2e:npm`) validates package-installed mode in an isolated sandbox.
|
|
90
|
+
- Set `OPENCODE_TODO_ENFORCER_E2E_STRICT=true` to fail E2E when telemetry events are missing.
|
|
91
|
+
|
|
92
|
+
## Telemetry for verification
|
|
93
|
+
|
|
94
|
+
This plugin emits optional JSONL telemetry events used by E2E checks:
|
|
95
|
+
|
|
96
|
+
- Default path: `~/.local/share/opencode/plugins/opencode-todo-enforcer/telemetry.jsonl`
|
|
97
|
+
- Override path: `OPENCODE_TODO_ENFORCER_TELEMETRY_PATH=/abs/path/file.jsonl`
|
|
98
|
+
- Add run context tags: `OPENCODE_TODO_ENFORCER_TELEMETRY_CONTEXT=case-id`
|
|
99
|
+
- Disable telemetry: `OPENCODE_TODO_ENFORCER_TELEMETRY=false`
|
|
100
|
+
|
|
101
|
+
## Runtime debug tool
|
|
102
|
+
|
|
103
|
+
Use the `todo_enforcer_debug_ping` tool to prove the plugin is loaded and executing.
|
|
104
|
+
|
|
105
|
+
- It writes a JSONL record to `<session-directory>/.opencode-todo-enforcer-debug-pings.jsonl`
|
|
106
|
+
- It returns `{ ok: true, marker, ping_path }`
|
|
107
|
+
|
|
108
|
+
## npm plugin sandbox
|
|
109
|
+
|
|
110
|
+
For manual OpenCode verification with the npm package (without local source shims):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
bun run opencode:npm
|
|
114
|
+
bun run opencode:npm:config
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Releasing
|
|
118
|
+
|
|
119
|
+
- Use `bun run release:verify` before version bumps.
|
|
120
|
+
- Use `bun run release:patch|minor|major|beta:first|beta:next` for tag/version creation.
|
|
121
|
+
- See `RELEASING.md` for full npm and GitHub release workflow details.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
//#region src/todo-enforcer/config.d.ts
|
|
4
|
+
interface TodoEnforcerOptions {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
prompt?: string;
|
|
7
|
+
stopCommand?: string;
|
|
8
|
+
skipAgents?: string[];
|
|
9
|
+
countdownMs?: number;
|
|
10
|
+
countdownGraceMs?: number;
|
|
11
|
+
continuationCooldownMs?: number;
|
|
12
|
+
abortWindowMs?: number;
|
|
13
|
+
failureResetWindowMs?: number;
|
|
14
|
+
maxConsecutiveFailures?: number;
|
|
15
|
+
sessionTtlMs?: number;
|
|
16
|
+
sessionPruneIntervalMs?: number;
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
guards?: {
|
|
19
|
+
abortWindow?: boolean;
|
|
20
|
+
backgroundTasks?: boolean;
|
|
21
|
+
skippedAgents?: boolean;
|
|
22
|
+
stopState?: boolean;
|
|
23
|
+
};
|
|
24
|
+
hasRunningBackgroundTasks?: (sessionID: string) => boolean;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/index.d.ts
|
|
29
|
+
declare const TodoEnforcerPlugin: Plugin;
|
|
30
|
+
declare const createTodoEnforcerPlugin: (options?: TodoEnforcerOptions) => Plugin;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { type TodoEnforcerOptions, TodoEnforcerPlugin, TodoEnforcerPlugin as default, createTodoEnforcerPlugin };
|
|
33
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
|
|
7
|
+
//#region src/todo-enforcer/constants.ts
|
|
8
|
+
const CONTINUATION_PROMPT = [
|
|
9
|
+
"Resume work from the current todo list.",
|
|
10
|
+
"Focus on the highest priority incomplete item.",
|
|
11
|
+
"If all tasks are done, update todos accordingly and stop."
|
|
12
|
+
].join(" ");
|
|
13
|
+
const STOP_CONTINUATION_COMMAND = "/stop-continuation";
|
|
14
|
+
const DEFAULT_COUNTDOWN_MS = 2e3;
|
|
15
|
+
const DEFAULT_COOLDOWN_MS = 5e3;
|
|
16
|
+
const DEFAULT_ABORT_WINDOW_MS = 3e3;
|
|
17
|
+
const DEFAULT_FAILURE_RESET_WINDOW_MS = 6e4;
|
|
18
|
+
const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
19
|
+
const DEFAULT_COUNTDOWN_GRACE_MS = 500;
|
|
20
|
+
const DEFAULT_SESSION_TTL_MS = 600 * 1e3;
|
|
21
|
+
const DEFAULT_PRUNE_INTERVAL_MS = 120 * 1e3;
|
|
22
|
+
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"];
|
|
23
|
+
const INTERNAL_INITIATOR_MARKER = "todo-enforcer:internal";
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/todo-enforcer/config.ts
|
|
27
|
+
const defaultNow = () => Date.now();
|
|
28
|
+
const envValue = (name) => {
|
|
29
|
+
const value = process.env[name]?.trim();
|
|
30
|
+
return value && value.length > 0 ? value : void 0;
|
|
31
|
+
};
|
|
32
|
+
const createTodoEnforcerConfig = (options) => {
|
|
33
|
+
return {
|
|
34
|
+
enabled: options?.enabled ?? true,
|
|
35
|
+
prompt: options?.prompt ?? CONTINUATION_PROMPT,
|
|
36
|
+
stopCommand: options?.stopCommand ?? envValue("OPENCODE_TODO_ENFORCER_STOP_COMMAND") ?? STOP_CONTINUATION_COMMAND,
|
|
37
|
+
skipAgents: options?.skipAgents ?? [...DEFAULT_SKIP_AGENTS],
|
|
38
|
+
countdownMs: options?.countdownMs ?? DEFAULT_COUNTDOWN_MS,
|
|
39
|
+
countdownGraceMs: options?.countdownGraceMs ?? DEFAULT_COUNTDOWN_GRACE_MS,
|
|
40
|
+
continuationCooldownMs: options?.continuationCooldownMs ?? DEFAULT_COOLDOWN_MS,
|
|
41
|
+
abortWindowMs: options?.abortWindowMs ?? DEFAULT_ABORT_WINDOW_MS,
|
|
42
|
+
failureResetWindowMs: options?.failureResetWindowMs ?? DEFAULT_FAILURE_RESET_WINDOW_MS,
|
|
43
|
+
maxConsecutiveFailures: options?.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
|
|
44
|
+
sessionTtlMs: options?.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
|
|
45
|
+
sessionPruneIntervalMs: options?.sessionPruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS,
|
|
46
|
+
debug: options?.debug ?? false,
|
|
47
|
+
guards: {
|
|
48
|
+
abortWindow: options?.guards?.abortWindow ?? true,
|
|
49
|
+
backgroundTasks: options?.guards?.backgroundTasks ?? true,
|
|
50
|
+
skippedAgents: options?.guards?.skippedAgents ?? true,
|
|
51
|
+
stopState: options?.guards?.stopState ?? true
|
|
52
|
+
},
|
|
53
|
+
hasRunningBackgroundTasks: options?.hasRunningBackgroundTasks,
|
|
54
|
+
now: options?.now ?? defaultNow
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/todo-enforcer/telemetry.ts
|
|
60
|
+
const defaultTelemetryPath = () => {
|
|
61
|
+
return path.join(os.homedir(), ".local", "share", "opencode", "plugins", "opencode-todo-enforcer", "telemetry.jsonl");
|
|
62
|
+
};
|
|
63
|
+
const swallowError$2 = (_error) => {};
|
|
64
|
+
const resolveTelemetryPath = () => {
|
|
65
|
+
const customPath = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY_PATH?.trim();
|
|
66
|
+
if (customPath) return customPath;
|
|
67
|
+
return defaultTelemetryPath();
|
|
68
|
+
};
|
|
69
|
+
const createTodoEnforcerTelemetry = () => {
|
|
70
|
+
const disabled = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY === "false";
|
|
71
|
+
const telemetryPath = resolveTelemetryPath();
|
|
72
|
+
const context = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY_CONTEXT?.trim();
|
|
73
|
+
let initialized = false;
|
|
74
|
+
const log = (entry) => {
|
|
75
|
+
if (disabled) return;
|
|
76
|
+
const payload = {
|
|
77
|
+
event: "todo_enforcer",
|
|
78
|
+
kind: entry.kind,
|
|
79
|
+
session_id: entry.sessionID,
|
|
80
|
+
reason: entry.reason,
|
|
81
|
+
metadata: entry.metadata,
|
|
82
|
+
context,
|
|
83
|
+
timestamp: Date.now()
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
if (!initialized) {
|
|
87
|
+
mkdirSync(path.dirname(telemetryPath), { recursive: true });
|
|
88
|
+
initialized = true;
|
|
89
|
+
}
|
|
90
|
+
appendFileSync(telemetryPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
swallowError$2(error);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
return { log };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/todo-enforcer/debug-tool.ts
|
|
100
|
+
const DEBUG_PING_FILENAME = ".opencode-todo-enforcer-debug-pings.jsonl";
|
|
101
|
+
const resolveDebugPingFilePath = (directory) => {
|
|
102
|
+
return path.join(directory, DEBUG_PING_FILENAME);
|
|
103
|
+
};
|
|
104
|
+
const telemetry = createTodoEnforcerTelemetry();
|
|
105
|
+
const todoEnforcerDebugPingTool = tool({
|
|
106
|
+
description: "Debug helper that proves opencode-todo-enforcer plugin/tool execution at runtime.",
|
|
107
|
+
args: { marker: tool.schema.string().optional().describe("Optional marker string echoed into debug ping records.") },
|
|
108
|
+
execute: async (args, context) => {
|
|
109
|
+
const marker = args.marker?.trim() || "default";
|
|
110
|
+
const pingPath = resolveDebugPingFilePath(context.directory);
|
|
111
|
+
const payload = {
|
|
112
|
+
event: "debug_ping",
|
|
113
|
+
marker,
|
|
114
|
+
sessionID: context.sessionID,
|
|
115
|
+
messageID: context.messageID,
|
|
116
|
+
agent: context.agent,
|
|
117
|
+
directory: context.directory,
|
|
118
|
+
worktree: context.worktree,
|
|
119
|
+
timestamp: Date.now()
|
|
120
|
+
};
|
|
121
|
+
await mkdir(path.dirname(pingPath), { recursive: true });
|
|
122
|
+
await appendFile(pingPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
123
|
+
telemetry.log({
|
|
124
|
+
kind: "debug_ping_tool",
|
|
125
|
+
sessionID: context.sessionID,
|
|
126
|
+
metadata: {
|
|
127
|
+
marker,
|
|
128
|
+
ping_path: pingPath
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
ok: true,
|
|
133
|
+
marker,
|
|
134
|
+
ping_path: pingPath
|
|
135
|
+
}, null, 2);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/todo-enforcer/abort-detection.ts
|
|
141
|
+
const ABORT_KEYWORDS = [
|
|
142
|
+
"aborted",
|
|
143
|
+
"abort",
|
|
144
|
+
"cancelled",
|
|
145
|
+
"canceled"
|
|
146
|
+
];
|
|
147
|
+
const containsAbortKeyword = (value) => {
|
|
148
|
+
if (!value) return false;
|
|
149
|
+
const normalized = value.toLowerCase();
|
|
150
|
+
return ABORT_KEYWORDS.some((keyword) => normalized.includes(keyword));
|
|
151
|
+
};
|
|
152
|
+
const isAbortLikeError = (error) => {
|
|
153
|
+
if (typeof error !== "object" || error === null) return false;
|
|
154
|
+
const maybeError = error;
|
|
155
|
+
return containsAbortKeyword(maybeError.type) || containsAbortKeyword(maybeError.name) || containsAbortKeyword(maybeError.message);
|
|
156
|
+
};
|
|
157
|
+
const isLastAssistantMessageAborted = (messages) => {
|
|
158
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const info = messages[index]?.info;
|
|
160
|
+
if (!info || info.role !== "assistant") continue;
|
|
161
|
+
if (containsAbortKeyword(info.error?.type)) return true;
|
|
162
|
+
if (containsAbortKeyword(info.error?.name)) return true;
|
|
163
|
+
if (containsAbortKeyword(info.error?.message)) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/todo-enforcer/response.ts
|
|
171
|
+
const isRecord$1 = (value) => {
|
|
172
|
+
return typeof value === "object" && value !== null;
|
|
173
|
+
};
|
|
174
|
+
const unwrapSdkResponse = (value, fallback) => {
|
|
175
|
+
if (!isRecord$1(value)) return fallback;
|
|
176
|
+
const data = value.data;
|
|
177
|
+
if (data === void 0) return fallback;
|
|
178
|
+
return data;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/todo-enforcer/todo.ts
|
|
183
|
+
const TERMINAL_STATUSES = new Set(["completed", "cancelled"]);
|
|
184
|
+
const getIncompleteTodoCount = (todos) => {
|
|
185
|
+
let count = 0;
|
|
186
|
+
for (const todo of todos) if (!TERMINAL_STATUSES.has(todo.status)) count += 1;
|
|
187
|
+
return count;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/todo-enforcer/continuation.ts
|
|
192
|
+
const swallowError$1 = (_error) => {};
|
|
193
|
+
const injectContinuation = async (args) => {
|
|
194
|
+
const { ctx, config, sessionID, state, resolvedInfo } = args;
|
|
195
|
+
try {
|
|
196
|
+
if (getIncompleteTodoCount(unwrapSdkResponse(await ctx.client.session.todo({ path: { id: sessionID } }), [])) === 0) return { status: "skipped-no-todos" };
|
|
197
|
+
state.inFlight = true;
|
|
198
|
+
await ctx.client.session.promptAsync({
|
|
199
|
+
path: { id: sessionID },
|
|
200
|
+
query: { directory: ctx.directory },
|
|
201
|
+
body: {
|
|
202
|
+
agent: resolvedInfo.agent,
|
|
203
|
+
model: resolvedInfo.model,
|
|
204
|
+
parts: [{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: `${config.prompt}\n[${INTERNAL_INITIATOR_MARKER}]`,
|
|
207
|
+
metadata: { [INTERNAL_INITIATOR_MARKER]: true }
|
|
208
|
+
}]
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
state.lastInjectedAt = config.now();
|
|
212
|
+
state.consecutiveFailures = 0;
|
|
213
|
+
return { status: "injected" };
|
|
214
|
+
} catch (_error) {
|
|
215
|
+
state.lastInjectedAt = config.now();
|
|
216
|
+
state.consecutiveFailures += 1;
|
|
217
|
+
await ctx.client.tui.showToast({ body: {
|
|
218
|
+
variant: "warning",
|
|
219
|
+
message: `Todo enforcer continuation failed for ${sessionID}; backing off.`
|
|
220
|
+
} }).catch(swallowError$1);
|
|
221
|
+
return { status: "failed" };
|
|
222
|
+
} finally {
|
|
223
|
+
state.inFlight = false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/todo-enforcer/countdown.ts
|
|
229
|
+
const cancelCountdown = (state) => {
|
|
230
|
+
if (state.countdownTimer) {
|
|
231
|
+
clearTimeout(state.countdownTimer);
|
|
232
|
+
state.countdownTimer = void 0;
|
|
233
|
+
}
|
|
234
|
+
if (state.warningTimer) {
|
|
235
|
+
clearTimeout(state.warningTimer);
|
|
236
|
+
state.warningTimer = void 0;
|
|
237
|
+
}
|
|
238
|
+
state.countdownStartedAt = void 0;
|
|
239
|
+
};
|
|
240
|
+
const swallowError = (_error) => {};
|
|
241
|
+
const startCountdown = (args) => {
|
|
242
|
+
const { ctx, config, state, sessionID, incompleteCount, onElapsed } = args;
|
|
243
|
+
cancelCountdown(state);
|
|
244
|
+
state.countdownStartedAt = config.now();
|
|
245
|
+
ctx.client.tui.showToast({ body: {
|
|
246
|
+
variant: "warning",
|
|
247
|
+
message: `Todo enforcer continuing in ${(config.countdownMs / 1e3).toFixed(1)}s (${incompleteCount} incomplete).`
|
|
248
|
+
} }).catch(swallowError);
|
|
249
|
+
state.warningTimer = setTimeout(() => {
|
|
250
|
+
ctx.client.tui.showToast({ body: {
|
|
251
|
+
variant: "warning",
|
|
252
|
+
message: `Todo enforcer continuing now for session ${sessionID}.`
|
|
253
|
+
} }).catch(swallowError);
|
|
254
|
+
}, Math.max(0, config.countdownMs - 1e3));
|
|
255
|
+
state.countdownTimer = setTimeout(() => {
|
|
256
|
+
state.countdownTimer = void 0;
|
|
257
|
+
state.warningTimer = void 0;
|
|
258
|
+
state.countdownStartedAt = void 0;
|
|
259
|
+
onElapsed().catch(swallowError);
|
|
260
|
+
}, config.countdownMs);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/todo-enforcer/guards.ts
|
|
265
|
+
const normalizeAgentKey = (value) => {
|
|
266
|
+
return value.toLowerCase().replace(/\([^)]*\)/g, "").replace(/[^a-z0-9]/g, "");
|
|
267
|
+
};
|
|
268
|
+
const isAgentSkipped = (skipAgents, agent) => {
|
|
269
|
+
const target = normalizeAgentKey(agent);
|
|
270
|
+
return skipAgents.some((item) => normalizeAgentKey(item) === target);
|
|
271
|
+
};
|
|
272
|
+
const evaluateIdleGuards = (input) => {
|
|
273
|
+
const { state, snapshot, config, isStopped, hasRunningBackgroundTasks } = input;
|
|
274
|
+
const now = config.now();
|
|
275
|
+
if (state.isRecovering) return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: "recovering"
|
|
278
|
+
};
|
|
279
|
+
if (config.guards.abortWindow && state.abortDetectedAt && now - state.abortDetectedAt < config.abortWindowMs) return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: "abort-window"
|
|
282
|
+
};
|
|
283
|
+
if (config.guards.backgroundTasks && hasRunningBackgroundTasks) return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: "background-running"
|
|
286
|
+
};
|
|
287
|
+
if (snapshot.todos.length === 0) return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: "todo-empty"
|
|
290
|
+
};
|
|
291
|
+
if (snapshot.incompleteCount === 0) return {
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: "todo-complete"
|
|
294
|
+
};
|
|
295
|
+
if (state.inFlight) return {
|
|
296
|
+
ok: false,
|
|
297
|
+
reason: "in-flight"
|
|
298
|
+
};
|
|
299
|
+
if (state.consecutiveFailures >= config.maxConsecutiveFailures && state.lastInjectedAt && now - state.lastInjectedAt >= config.failureResetWindowMs) state.consecutiveFailures = 0;
|
|
300
|
+
if (state.consecutiveFailures >= config.maxConsecutiveFailures) return {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: "max-failures"
|
|
303
|
+
};
|
|
304
|
+
const effectiveCooldown = config.continuationCooldownMs * 2 ** Math.min(state.consecutiveFailures, config.maxConsecutiveFailures);
|
|
305
|
+
if (state.lastInjectedAt && now - state.lastInjectedAt < effectiveCooldown) return {
|
|
306
|
+
ok: false,
|
|
307
|
+
reason: "cooldown"
|
|
308
|
+
};
|
|
309
|
+
if (config.guards.skippedAgents && snapshot.resolvedInfo.agent && isAgentSkipped(config.skipAgents, snapshot.resolvedInfo.agent)) return {
|
|
310
|
+
ok: false,
|
|
311
|
+
reason: "skipped-agent"
|
|
312
|
+
};
|
|
313
|
+
if (config.guards.stopState && isStopped) return {
|
|
314
|
+
ok: false,
|
|
315
|
+
reason: "stop-state"
|
|
316
|
+
};
|
|
317
|
+
return { ok: true };
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/todo-enforcer/session-state.ts
|
|
322
|
+
const createInitialState = () => {
|
|
323
|
+
return {
|
|
324
|
+
inFlight: false,
|
|
325
|
+
isRecovering: false,
|
|
326
|
+
consecutiveFailures: 0
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
const createSessionStateStore = (config) => {
|
|
330
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
331
|
+
let lastPruneAt = config.now();
|
|
332
|
+
const clearTimers = (state) => {
|
|
333
|
+
if (state.countdownTimer) {
|
|
334
|
+
clearTimeout(state.countdownTimer);
|
|
335
|
+
state.countdownTimer = void 0;
|
|
336
|
+
}
|
|
337
|
+
if (state.warningTimer) {
|
|
338
|
+
clearTimeout(state.warningTimer);
|
|
339
|
+
state.warningTimer = void 0;
|
|
340
|
+
}
|
|
341
|
+
state.countdownStartedAt = void 0;
|
|
342
|
+
};
|
|
343
|
+
const get = (sessionID) => {
|
|
344
|
+
const existing = sessions.get(sessionID);
|
|
345
|
+
if (existing) {
|
|
346
|
+
existing.touchedAt = config.now();
|
|
347
|
+
return existing.state;
|
|
348
|
+
}
|
|
349
|
+
const created = createInitialState();
|
|
350
|
+
sessions.set(sessionID, {
|
|
351
|
+
state: created,
|
|
352
|
+
touchedAt: config.now()
|
|
353
|
+
});
|
|
354
|
+
return created;
|
|
355
|
+
};
|
|
356
|
+
const touch = (sessionID) => {
|
|
357
|
+
const existing = sessions.get(sessionID);
|
|
358
|
+
if (existing) {
|
|
359
|
+
existing.touchedAt = config.now();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
get(sessionID);
|
|
363
|
+
};
|
|
364
|
+
const clear = (sessionID) => {
|
|
365
|
+
const existing = sessions.get(sessionID);
|
|
366
|
+
if (!existing) return;
|
|
367
|
+
clearTimers(existing.state);
|
|
368
|
+
sessions.delete(sessionID);
|
|
369
|
+
};
|
|
370
|
+
const clearAll = () => {
|
|
371
|
+
for (const entry of sessions.values()) clearTimers(entry.state);
|
|
372
|
+
sessions.clear();
|
|
373
|
+
};
|
|
374
|
+
const prune = () => {
|
|
375
|
+
const now = config.now();
|
|
376
|
+
if (now - lastPruneAt < config.sessionPruneIntervalMs) return;
|
|
377
|
+
lastPruneAt = now;
|
|
378
|
+
for (const [sessionID, entry] of sessions.entries()) {
|
|
379
|
+
if (now - entry.touchedAt <= config.sessionTtlMs) continue;
|
|
380
|
+
clearTimers(entry.state);
|
|
381
|
+
sessions.delete(sessionID);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
get,
|
|
386
|
+
touch,
|
|
387
|
+
clear,
|
|
388
|
+
clearAll,
|
|
389
|
+
prune
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/todo-enforcer/orchestrator.ts
|
|
395
|
+
const NO_OP = (_error) => {};
|
|
396
|
+
const isRecord = (value) => {
|
|
397
|
+
return typeof value === "object" && value !== null;
|
|
398
|
+
};
|
|
399
|
+
const extractSessionID = (event) => {
|
|
400
|
+
if (!isRecord(event.properties)) return;
|
|
401
|
+
const properties = event.properties;
|
|
402
|
+
const fromProperties = properties.sessionID;
|
|
403
|
+
if (typeof fromProperties === "string") return fromProperties;
|
|
404
|
+
const info = properties.info;
|
|
405
|
+
if (!isRecord(info)) return;
|
|
406
|
+
const fromInfo = info.sessionID ?? info.id;
|
|
407
|
+
return typeof fromInfo === "string" ? fromInfo : void 0;
|
|
408
|
+
};
|
|
409
|
+
const extractResolvedInfo = (messages) => {
|
|
410
|
+
let sawCompaction = false;
|
|
411
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
412
|
+
const info = messages[index].info;
|
|
413
|
+
if (!info) continue;
|
|
414
|
+
if (info.agent?.toLowerCase() === "compaction") {
|
|
415
|
+
sawCompaction = true;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (info.agent || info.model) return {
|
|
419
|
+
agent: info.agent,
|
|
420
|
+
model: info.model
|
|
421
|
+
};
|
|
422
|
+
if (info.providerID && info.modelID) return {
|
|
423
|
+
agent: info.agent,
|
|
424
|
+
model: {
|
|
425
|
+
providerID: info.providerID,
|
|
426
|
+
modelID: info.modelID
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (sawCompaction) return { agent: "compaction" };
|
|
431
|
+
return {};
|
|
432
|
+
};
|
|
433
|
+
const extractMessageRole = (event) => {
|
|
434
|
+
if (!isRecord(event.properties)) return;
|
|
435
|
+
const info = event.properties.info;
|
|
436
|
+
if (!isRecord(info)) return;
|
|
437
|
+
return typeof info.role === "string" ? info.role : void 0;
|
|
438
|
+
};
|
|
439
|
+
const extractUserText = (parts) => {
|
|
440
|
+
const textParts = parts.filter((part) => {
|
|
441
|
+
return part.type === "text";
|
|
442
|
+
});
|
|
443
|
+
const chunks = [];
|
|
444
|
+
for (const part of textParts) chunks.push(part.text);
|
|
445
|
+
return chunks.join("\n").trim();
|
|
446
|
+
};
|
|
447
|
+
const createTodoEnforcerOrchestrator = ({ ctx, config, stopState }) => {
|
|
448
|
+
const states = createSessionStateStore(config);
|
|
449
|
+
const telemetry = createTodoEnforcerTelemetry();
|
|
450
|
+
const cancelForActivity = (sessionID) => {
|
|
451
|
+
const state = states.get(sessionID);
|
|
452
|
+
const hadCountdown = Boolean(state.countdownStartedAt);
|
|
453
|
+
if (state.countdownStartedAt) telemetry.log({
|
|
454
|
+
kind: "countdown_cancelled",
|
|
455
|
+
sessionID,
|
|
456
|
+
reason: "activity"
|
|
457
|
+
});
|
|
458
|
+
cancelCountdown(state);
|
|
459
|
+
if (hadCountdown) state.userActivityAt = config.now();
|
|
460
|
+
states.touch(sessionID);
|
|
461
|
+
};
|
|
462
|
+
const cancelCountdownForSession = (sessionID) => {
|
|
463
|
+
const state = states.get(sessionID);
|
|
464
|
+
const hadCountdown = Boolean(state.countdownStartedAt);
|
|
465
|
+
if (state.countdownStartedAt) telemetry.log({
|
|
466
|
+
kind: "countdown_cancelled",
|
|
467
|
+
sessionID,
|
|
468
|
+
reason: "session_event"
|
|
469
|
+
});
|
|
470
|
+
cancelCountdown(state);
|
|
471
|
+
if (hadCountdown) state.userActivityAt = config.now();
|
|
472
|
+
states.touch(sessionID);
|
|
473
|
+
};
|
|
474
|
+
const handleIdle = async (sessionID) => {
|
|
475
|
+
const state = states.get(sessionID);
|
|
476
|
+
states.prune();
|
|
477
|
+
telemetry.log({
|
|
478
|
+
kind: "idle_seen",
|
|
479
|
+
sessionID
|
|
480
|
+
});
|
|
481
|
+
if (state.userActivityAt && config.now() - state.userActivityAt < config.countdownGraceMs) {
|
|
482
|
+
telemetry.log({
|
|
483
|
+
kind: "idle_skipped",
|
|
484
|
+
sessionID,
|
|
485
|
+
reason: "post-cancel-grace"
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let todos = [];
|
|
490
|
+
try {
|
|
491
|
+
todos = unwrapSdkResponse(await ctx.client.session.todo({ path: { id: sessionID } }), []);
|
|
492
|
+
} catch (_error) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
let messages = [];
|
|
496
|
+
try {
|
|
497
|
+
messages = unwrapSdkResponse(await ctx.client.session.messages({
|
|
498
|
+
path: { id: sessionID },
|
|
499
|
+
query: { directory: ctx.directory }
|
|
500
|
+
}), []);
|
|
501
|
+
} catch (_error) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (isLastAssistantMessageAborted(messages)) state.abortDetectedAt = config.now();
|
|
505
|
+
const resolvedInfo = extractResolvedInfo(messages);
|
|
506
|
+
const snapshot = {
|
|
507
|
+
todos,
|
|
508
|
+
incompleteCount: getIncompleteTodoCount(todos),
|
|
509
|
+
resolvedInfo
|
|
510
|
+
};
|
|
511
|
+
const decision = evaluateIdleGuards({
|
|
512
|
+
state,
|
|
513
|
+
snapshot,
|
|
514
|
+
config,
|
|
515
|
+
isStopped: stopState.isStopped(sessionID),
|
|
516
|
+
hasRunningBackgroundTasks: config.hasRunningBackgroundTasks?.(sessionID) ?? false
|
|
517
|
+
});
|
|
518
|
+
if (!decision.ok) {
|
|
519
|
+
telemetry.log({
|
|
520
|
+
kind: "idle_skipped",
|
|
521
|
+
sessionID,
|
|
522
|
+
reason: decision.reason
|
|
523
|
+
});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
telemetry.log({
|
|
527
|
+
kind: "countdown_started",
|
|
528
|
+
sessionID
|
|
529
|
+
});
|
|
530
|
+
startCountdown({
|
|
531
|
+
ctx,
|
|
532
|
+
config,
|
|
533
|
+
state,
|
|
534
|
+
sessionID,
|
|
535
|
+
incompleteCount: snapshot.incompleteCount,
|
|
536
|
+
onElapsed: async () => {
|
|
537
|
+
const stateAfterCountdown = states.get(sessionID);
|
|
538
|
+
if (stateAfterCountdown.userActivityAt && config.now() - stateAfterCountdown.userActivityAt < config.countdownGraceMs) return;
|
|
539
|
+
const result = await injectContinuation({
|
|
540
|
+
ctx,
|
|
541
|
+
config,
|
|
542
|
+
sessionID,
|
|
543
|
+
state: stateAfterCountdown,
|
|
544
|
+
resolvedInfo
|
|
545
|
+
});
|
|
546
|
+
telemetry.log({
|
|
547
|
+
kind: result.status === "injected" ? "injected" : "injection_skipped",
|
|
548
|
+
sessionID,
|
|
549
|
+
reason: result.status
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
const handleSessionError = (sessionID, event) => {
|
|
555
|
+
const state = states.get(sessionID);
|
|
556
|
+
if (event.type === "session.error" && isAbortLikeError(event.properties.error)) {
|
|
557
|
+
state.abortDetectedAt = config.now();
|
|
558
|
+
telemetry.log({
|
|
559
|
+
kind: "abort_detected",
|
|
560
|
+
sessionID
|
|
561
|
+
});
|
|
562
|
+
} else {
|
|
563
|
+
state.abortDetectedAt = void 0;
|
|
564
|
+
telemetry.log({
|
|
565
|
+
kind: "non_abort_error",
|
|
566
|
+
sessionID
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
cancelCountdownForSession(sessionID);
|
|
570
|
+
};
|
|
571
|
+
const handleCommandExecuted = (sessionID, event) => {
|
|
572
|
+
if (event.type !== "command.executed" || !isRecord(event.properties)) return;
|
|
573
|
+
if (event.properties.name === config.stopCommand.replace("/", "")) {
|
|
574
|
+
stopState.setStopped(sessionID, true);
|
|
575
|
+
telemetry.log({
|
|
576
|
+
kind: "stop_set_command",
|
|
577
|
+
sessionID
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
const shouldIgnoreUserActivity = (sessionID, event) => {
|
|
582
|
+
if (extractMessageRole(event) !== "user") return false;
|
|
583
|
+
const state = states.get(sessionID);
|
|
584
|
+
return Boolean(state.countdownStartedAt && config.now() - state.countdownStartedAt < config.countdownGraceMs);
|
|
585
|
+
};
|
|
586
|
+
const onEvent = async (input) => {
|
|
587
|
+
if (!config.enabled) return;
|
|
588
|
+
const { event } = input;
|
|
589
|
+
const sessionID = extractSessionID(event);
|
|
590
|
+
if (!sessionID) return;
|
|
591
|
+
switch (event.type) {
|
|
592
|
+
case "session.idle":
|
|
593
|
+
await handleIdle(sessionID);
|
|
594
|
+
return;
|
|
595
|
+
case "session.deleted":
|
|
596
|
+
states.clear(sessionID);
|
|
597
|
+
stopState.clear(sessionID);
|
|
598
|
+
telemetry.log({
|
|
599
|
+
kind: "session_deleted",
|
|
600
|
+
sessionID
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
case "session.error":
|
|
604
|
+
handleSessionError(sessionID, event);
|
|
605
|
+
return;
|
|
606
|
+
case "command.executed":
|
|
607
|
+
handleCommandExecuted(sessionID, event);
|
|
608
|
+
return;
|
|
609
|
+
case "message.updated":
|
|
610
|
+
if (shouldIgnoreUserActivity(sessionID, event)) return;
|
|
611
|
+
cancelForActivity(sessionID);
|
|
612
|
+
return;
|
|
613
|
+
case "message.part.updated":
|
|
614
|
+
case "session.status":
|
|
615
|
+
cancelForActivity(sessionID);
|
|
616
|
+
return;
|
|
617
|
+
default: return;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const onChatMessage = async (input, output) => {
|
|
621
|
+
if (!config.enabled) return;
|
|
622
|
+
const text = extractUserText(output.parts);
|
|
623
|
+
telemetry.log({
|
|
624
|
+
kind: "chat_message_seen",
|
|
625
|
+
sessionID: input.sessionID
|
|
626
|
+
});
|
|
627
|
+
if (text === config.stopCommand) {
|
|
628
|
+
stopState.setStopped(input.sessionID, true);
|
|
629
|
+
telemetry.log({
|
|
630
|
+
kind: "stop_set_chat",
|
|
631
|
+
sessionID: input.sessionID
|
|
632
|
+
});
|
|
633
|
+
await ctx.client.tui.showToast({ body: {
|
|
634
|
+
variant: "warning",
|
|
635
|
+
message: "Todo continuation paused for this session."
|
|
636
|
+
} }).catch(NO_OP);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (stopState.isStopped(input.sessionID)) {
|
|
640
|
+
stopState.setStopped(input.sessionID, false);
|
|
641
|
+
telemetry.log({
|
|
642
|
+
kind: "stop_cleared_chat",
|
|
643
|
+
sessionID: input.sessionID
|
|
644
|
+
});
|
|
645
|
+
await ctx.client.tui.showToast({ body: {
|
|
646
|
+
variant: "info",
|
|
647
|
+
message: "Todo continuation resumed for this session."
|
|
648
|
+
} }).catch(NO_OP);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
const onToolExecuteBefore = (input) => {
|
|
652
|
+
if (!config.enabled) return;
|
|
653
|
+
cancelForActivity(input.sessionID);
|
|
654
|
+
};
|
|
655
|
+
const onToolExecuteAfter = (input) => {
|
|
656
|
+
if (!config.enabled) return;
|
|
657
|
+
cancelForActivity(input.sessionID);
|
|
658
|
+
};
|
|
659
|
+
return {
|
|
660
|
+
onEvent,
|
|
661
|
+
onChatMessage,
|
|
662
|
+
onToolExecuteBefore,
|
|
663
|
+
onToolExecuteAfter
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/todo-enforcer/stop-state.ts
|
|
669
|
+
const createStopStateStore = () => {
|
|
670
|
+
const stoppedSessions = /* @__PURE__ */ new Set();
|
|
671
|
+
return {
|
|
672
|
+
isStopped: (sessionID) => stoppedSessions.has(sessionID),
|
|
673
|
+
setStopped: (sessionID, value) => {
|
|
674
|
+
if (value) {
|
|
675
|
+
stoppedSessions.add(sessionID);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
stoppedSessions.delete(sessionID);
|
|
679
|
+
},
|
|
680
|
+
clear: (sessionID) => {
|
|
681
|
+
stoppedSessions.delete(sessionID);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/index.ts
|
|
688
|
+
const TodoEnforcerPlugin = (input) => {
|
|
689
|
+
const orchestrator = createTodoEnforcerOrchestrator({
|
|
690
|
+
ctx: input,
|
|
691
|
+
config: createTodoEnforcerConfig(),
|
|
692
|
+
stopState: createStopStateStore()
|
|
693
|
+
});
|
|
694
|
+
return Promise.resolve({
|
|
695
|
+
tool: { todo_enforcer_debug_ping: todoEnforcerDebugPingTool },
|
|
696
|
+
event: orchestrator.onEvent,
|
|
697
|
+
"chat.message": async (payload, output) => {
|
|
698
|
+
await orchestrator.onChatMessage(payload, output);
|
|
699
|
+
},
|
|
700
|
+
"tool.execute.before": async (payload) => {
|
|
701
|
+
await orchestrator.onToolExecuteBefore(payload);
|
|
702
|
+
},
|
|
703
|
+
"tool.execute.after": async (payload) => {
|
|
704
|
+
await orchestrator.onToolExecuteAfter(payload);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
const createTodoEnforcerPlugin = (options) => {
|
|
709
|
+
return (input) => {
|
|
710
|
+
const orchestrator = createTodoEnforcerOrchestrator({
|
|
711
|
+
ctx: input,
|
|
712
|
+
config: createTodoEnforcerConfig(options),
|
|
713
|
+
stopState: createStopStateStore()
|
|
714
|
+
});
|
|
715
|
+
return Promise.resolve({
|
|
716
|
+
tool: { todo_enforcer_debug_ping: todoEnforcerDebugPingTool },
|
|
717
|
+
event: orchestrator.onEvent,
|
|
718
|
+
"chat.message": async (payload, output) => {
|
|
719
|
+
await orchestrator.onChatMessage(payload, output);
|
|
720
|
+
},
|
|
721
|
+
"tool.execute.before": async (payload) => {
|
|
722
|
+
await orchestrator.onToolExecuteBefore(payload);
|
|
723
|
+
},
|
|
724
|
+
"tool.execute.after": async (payload) => {
|
|
725
|
+
await orchestrator.onToolExecuteAfter(payload);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
//#endregion
|
|
732
|
+
export { TodoEnforcerPlugin, TodoEnforcerPlugin as default, createTodoEnforcerPlugin };
|
|
733
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["swallowError","isRecord","swallowError"],"sources":["../src/todo-enforcer/constants.ts","../src/todo-enforcer/config.ts","../src/todo-enforcer/telemetry.ts","../src/todo-enforcer/debug-tool.ts","../src/todo-enforcer/abort-detection.ts","../src/todo-enforcer/response.ts","../src/todo-enforcer/todo.ts","../src/todo-enforcer/continuation.ts","../src/todo-enforcer/countdown.ts","../src/todo-enforcer/guards.ts","../src/todo-enforcer/session-state.ts","../src/todo-enforcer/orchestrator.ts","../src/todo-enforcer/stop-state.ts","../src/index.ts"],"sourcesContent":["export const TODO_ENFORCER_NAME = \"todo-continuation-enforcer\";\n\nexport const CONTINUATION_PROMPT = [\n \"Resume work from the current todo list.\",\n \"Focus on the highest priority incomplete item.\",\n \"If all tasks are done, update todos accordingly and stop.\",\n].join(\" \");\n\nexport const STOP_CONTINUATION_COMMAND = \"/stop-continuation\";\n\nexport const DEFAULT_COUNTDOWN_MS = 2000;\nexport const DEFAULT_COOLDOWN_MS = 5000;\nexport const DEFAULT_ABORT_WINDOW_MS = 3000;\nexport const DEFAULT_FAILURE_RESET_WINDOW_MS = 60_000;\nexport const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;\nexport const DEFAULT_COUNTDOWN_GRACE_MS = 500;\nexport const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;\nexport const DEFAULT_PRUNE_INTERVAL_MS = 2 * 60 * 1000;\n\nexport const DEFAULT_SKIP_AGENTS = [\"prometheus\", \"compaction\"] as const;\nexport const INTERNAL_INITIATOR_MARKER = \"todo-enforcer:internal\";\n","import {\n CONTINUATION_PROMPT,\n DEFAULT_ABORT_WINDOW_MS,\n DEFAULT_COOLDOWN_MS,\n DEFAULT_COUNTDOWN_GRACE_MS,\n DEFAULT_COUNTDOWN_MS,\n DEFAULT_FAILURE_RESET_WINDOW_MS,\n DEFAULT_MAX_CONSECUTIVE_FAILURES,\n DEFAULT_PRUNE_INTERVAL_MS,\n DEFAULT_SESSION_TTL_MS,\n DEFAULT_SKIP_AGENTS,\n STOP_CONTINUATION_COMMAND,\n} from \"./constants\";\nimport type { TodoEnforcerConfig } from \"./types\";\n\nexport interface TodoEnforcerOptions {\n enabled?: boolean;\n prompt?: string;\n stopCommand?: string;\n skipAgents?: string[];\n countdownMs?: number;\n countdownGraceMs?: number;\n continuationCooldownMs?: number;\n abortWindowMs?: number;\n failureResetWindowMs?: number;\n maxConsecutiveFailures?: number;\n sessionTtlMs?: number;\n sessionPruneIntervalMs?: number;\n debug?: boolean;\n guards?: {\n abortWindow?: boolean;\n backgroundTasks?: boolean;\n skippedAgents?: boolean;\n stopState?: boolean;\n };\n hasRunningBackgroundTasks?: (sessionID: string) => boolean;\n now?: () => number;\n}\n\nconst defaultNow = (): number => Date.now();\n\nconst envValue = (name: string): string | undefined => {\n const value = process.env[name]?.trim();\n return value && value.length > 0 ? value : undefined;\n};\n\nexport const createTodoEnforcerConfig = (\n options?: TodoEnforcerOptions\n): TodoEnforcerConfig => {\n return {\n enabled: options?.enabled ?? true,\n prompt: options?.prompt ?? CONTINUATION_PROMPT,\n stopCommand:\n options?.stopCommand ??\n envValue(\"OPENCODE_TODO_ENFORCER_STOP_COMMAND\") ??\n STOP_CONTINUATION_COMMAND,\n skipAgents: options?.skipAgents ?? [...DEFAULT_SKIP_AGENTS],\n countdownMs: options?.countdownMs ?? DEFAULT_COUNTDOWN_MS,\n countdownGraceMs: options?.countdownGraceMs ?? DEFAULT_COUNTDOWN_GRACE_MS,\n continuationCooldownMs:\n options?.continuationCooldownMs ?? DEFAULT_COOLDOWN_MS,\n abortWindowMs: options?.abortWindowMs ?? DEFAULT_ABORT_WINDOW_MS,\n failureResetWindowMs:\n options?.failureResetWindowMs ?? DEFAULT_FAILURE_RESET_WINDOW_MS,\n maxConsecutiveFailures:\n options?.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,\n sessionTtlMs: options?.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,\n sessionPruneIntervalMs:\n options?.sessionPruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS,\n debug: options?.debug ?? false,\n guards: {\n abortWindow: options?.guards?.abortWindow ?? true,\n backgroundTasks: options?.guards?.backgroundTasks ?? true,\n skippedAgents: options?.guards?.skippedAgents ?? true,\n stopState: options?.guards?.stopState ?? true,\n },\n hasRunningBackgroundTasks: options?.hasRunningBackgroundTasks,\n now: options?.now ?? defaultNow,\n };\n};\n","import { appendFileSync, mkdirSync } from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\ninterface TelemetryEventInput {\n kind: string;\n sessionID?: string;\n reason?: string;\n metadata?: Record<string, unknown>;\n}\n\ninterface TelemetryEventRecord {\n event: \"todo_enforcer\";\n kind: string;\n session_id?: string;\n reason?: string;\n metadata?: Record<string, unknown>;\n context?: string;\n timestamp: number;\n}\n\nconst defaultTelemetryPath = (): string => {\n return path.join(\n os.homedir(),\n \".local\",\n \"share\",\n \"opencode\",\n \"plugins\",\n \"opencode-todo-enforcer\",\n \"telemetry.jsonl\"\n );\n};\n\nconst swallowError = (_error: unknown): undefined => {\n return undefined;\n};\n\nconst resolveTelemetryPath = (): string => {\n const customPath = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY_PATH?.trim();\n if (customPath) {\n return customPath;\n }\n return defaultTelemetryPath();\n};\n\nexport const createTodoEnforcerTelemetry = (): {\n log: (entry: TelemetryEventInput) => void;\n} => {\n const disabled = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY === \"false\";\n const telemetryPath = resolveTelemetryPath();\n const context = process.env.OPENCODE_TODO_ENFORCER_TELEMETRY_CONTEXT?.trim();\n\n let initialized = false;\n\n const log = (entry: TelemetryEventInput): void => {\n if (disabled) {\n return;\n }\n\n const payload: TelemetryEventRecord = {\n event: \"todo_enforcer\",\n kind: entry.kind,\n session_id: entry.sessionID,\n reason: entry.reason,\n metadata: entry.metadata,\n context,\n timestamp: Date.now(),\n };\n\n try {\n if (!initialized) {\n mkdirSync(path.dirname(telemetryPath), {\n recursive: true,\n });\n initialized = true;\n }\n\n appendFileSync(telemetryPath, `${JSON.stringify(payload)}\\n`, \"utf8\");\n } catch (error) {\n swallowError(error);\n }\n };\n\n return { log };\n};\n","import { appendFile, mkdir } from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport { tool } from \"@opencode-ai/plugin\";\n\nimport { createTodoEnforcerTelemetry } from \"./telemetry\";\n\nexport const DEBUG_PING_FILENAME = \".opencode-todo-enforcer-debug-pings.jsonl\";\n\nexport const resolveDebugPingFilePath = (directory: string): string => {\n return path.join(directory, DEBUG_PING_FILENAME);\n};\n\nconst telemetry = createTodoEnforcerTelemetry();\n\nexport const todoEnforcerDebugPingTool = tool({\n description:\n \"Debug helper that proves opencode-todo-enforcer plugin/tool execution at runtime.\",\n args: {\n marker: tool.schema\n .string()\n .optional()\n .describe(\"Optional marker string echoed into debug ping records.\"),\n },\n execute: async (args, context) => {\n const marker = args.marker?.trim() || \"default\";\n const pingPath = resolveDebugPingFilePath(context.directory);\n const payload = {\n event: \"debug_ping\",\n marker,\n sessionID: context.sessionID,\n messageID: context.messageID,\n agent: context.agent,\n directory: context.directory,\n worktree: context.worktree,\n timestamp: Date.now(),\n };\n\n await mkdir(path.dirname(pingPath), { recursive: true });\n await appendFile(pingPath, `${JSON.stringify(payload)}\\n`, \"utf8\");\n\n telemetry.log({\n kind: \"debug_ping_tool\",\n sessionID: context.sessionID,\n metadata: {\n marker,\n ping_path: pingPath,\n },\n });\n\n return JSON.stringify(\n {\n ok: true,\n marker,\n ping_path: pingPath,\n },\n null,\n 2\n );\n },\n});\n","import type { PromptMessage } from \"./types\";\n\nconst ABORT_KEYWORDS = [\"aborted\", \"abort\", \"cancelled\", \"canceled\"];\n\nexport const containsAbortKeyword = (value: string | undefined): boolean => {\n if (!value) {\n return false;\n }\n const normalized = value.toLowerCase();\n return ABORT_KEYWORDS.some((keyword) => normalized.includes(keyword));\n};\n\nexport const isAbortLikeError = (error: unknown): boolean => {\n if (typeof error !== \"object\" || error === null) {\n return false;\n }\n\n const maybeError = error as {\n name?: string;\n type?: string;\n message?: string;\n };\n\n return (\n containsAbortKeyword(maybeError.type) ||\n containsAbortKeyword(maybeError.name) ||\n containsAbortKeyword(maybeError.message)\n );\n};\n\nexport const isLastAssistantMessageAborted = (\n messages: PromptMessage[]\n): boolean => {\n for (let index = messages.length - 1; index >= 0; index -= 1) {\n const info = messages[index]?.info;\n if (!info || info.role !== \"assistant\") {\n continue;\n }\n\n if (containsAbortKeyword(info.error?.type)) {\n return true;\n }\n if (containsAbortKeyword(info.error?.name)) {\n return true;\n }\n if (containsAbortKeyword(info.error?.message)) {\n return true;\n }\n\n return false;\n }\n\n return false;\n};\n","const isRecord = (value: unknown): value is Record<string, unknown> => {\n return typeof value === \"object\" && value !== null;\n};\n\nexport const unwrapSdkResponse = <T>(value: unknown, fallback: T): T => {\n if (!isRecord(value)) {\n return fallback;\n }\n\n const data = value.data;\n if (data === undefined) {\n return fallback;\n }\n\n return data as T;\n};\n","import type { Todo } from \"@opencode-ai/sdk\";\n\nconst TERMINAL_STATUSES = new Set([\"completed\", \"cancelled\"]);\n\nexport const getIncompleteTodoCount = (todos: Todo[]): number => {\n let count = 0;\n for (const todo of todos) {\n if (!TERMINAL_STATUSES.has(todo.status)) {\n count += 1;\n }\n }\n return count;\n};\n","import type { PluginInput } from \"@opencode-ai/plugin\";\nimport type { Todo } from \"@opencode-ai/sdk\";\n\nimport { INTERNAL_INITIATOR_MARKER } from \"./constants\";\nimport { unwrapSdkResponse } from \"./response\";\nimport { getIncompleteTodoCount } from \"./todo\";\nimport type {\n SessionAgentInfo,\n SessionState,\n TodoEnforcerConfig,\n} from \"./types\";\n\nconst swallowError = (_error: unknown): undefined => {\n return undefined;\n};\n\nexport type ContinuationResult =\n | { status: \"injected\" }\n | { status: \"skipped-no-todos\" }\n | { status: \"failed\" };\n\nexport const injectContinuation = async (args: {\n ctx: PluginInput;\n config: TodoEnforcerConfig;\n sessionID: string;\n state: SessionState;\n resolvedInfo: SessionAgentInfo;\n}): Promise<ContinuationResult> => {\n const { ctx, config, sessionID, state, resolvedInfo } = args;\n\n try {\n const todoResponse = await ctx.client.session.todo({\n path: { id: sessionID },\n });\n const todos = unwrapSdkResponse(todoResponse, [] as Todo[]);\n const incompleteCount = getIncompleteTodoCount(todos);\n if (incompleteCount === 0) {\n return { status: \"skipped-no-todos\" };\n }\n\n state.inFlight = true;\n await ctx.client.session.promptAsync({\n path: { id: sessionID },\n query: { directory: ctx.directory },\n body: {\n agent: resolvedInfo.agent,\n model: resolvedInfo.model,\n parts: [\n {\n type: \"text\",\n text: `${config.prompt}\\n[${INTERNAL_INITIATOR_MARKER}]`,\n metadata: {\n [INTERNAL_INITIATOR_MARKER]: true,\n },\n },\n ],\n },\n });\n\n state.lastInjectedAt = config.now();\n state.consecutiveFailures = 0;\n return { status: \"injected\" };\n } catch (_error) {\n state.lastInjectedAt = config.now();\n state.consecutiveFailures += 1;\n await ctx.client.tui\n .showToast({\n body: {\n variant: \"warning\",\n message: `Todo enforcer continuation failed for ${sessionID}; backing off.`,\n },\n })\n .catch(swallowError);\n return { status: \"failed\" };\n } finally {\n state.inFlight = false;\n }\n};\n","import type { PluginInput } from \"@opencode-ai/plugin\";\n\nimport type { SessionState, TodoEnforcerConfig } from \"./types\";\n\nexport const cancelCountdown = (state: SessionState): void => {\n if (state.countdownTimer) {\n clearTimeout(state.countdownTimer);\n state.countdownTimer = undefined;\n }\n if (state.warningTimer) {\n clearTimeout(state.warningTimer);\n state.warningTimer = undefined;\n }\n state.countdownStartedAt = undefined;\n};\n\nconst swallowError = (_error: unknown): undefined => {\n return undefined;\n};\n\nexport const startCountdown = (args: {\n ctx: PluginInput;\n config: TodoEnforcerConfig;\n state: SessionState;\n sessionID: string;\n incompleteCount: number;\n onElapsed: () => Promise<void>;\n}): void => {\n const { ctx, config, state, sessionID, incompleteCount, onElapsed } = args;\n\n cancelCountdown(state);\n state.countdownStartedAt = config.now();\n\n ctx.client.tui\n .showToast({\n body: {\n variant: \"warning\",\n message: `Todo enforcer continuing in ${(config.countdownMs / 1000).toFixed(1)}s (${incompleteCount} incomplete).`,\n },\n })\n .catch(swallowError);\n\n state.warningTimer = setTimeout(\n () => {\n ctx.client.tui\n .showToast({\n body: {\n variant: \"warning\",\n message: `Todo enforcer continuing now for session ${sessionID}.`,\n },\n })\n .catch(swallowError);\n },\n Math.max(0, config.countdownMs - 1000)\n );\n\n state.countdownTimer = setTimeout(() => {\n state.countdownTimer = undefined;\n state.warningTimer = undefined;\n state.countdownStartedAt = undefined;\n onElapsed().catch(swallowError);\n }, config.countdownMs);\n};\n","import type { IdleSnapshot, SessionState, TodoEnforcerConfig } from \"./types\";\n\nexport type GuardDecision =\n | { ok: true }\n | {\n ok: false;\n reason:\n | \"recovering\"\n | \"abort-window\"\n | \"background-running\"\n | \"todo-empty\"\n | \"todo-complete\"\n | \"in-flight\"\n | \"max-failures\"\n | \"cooldown\"\n | \"skipped-agent\"\n | \"stop-state\";\n };\n\ninterface GuardInput {\n state: SessionState;\n snapshot: IdleSnapshot;\n config: TodoEnforcerConfig;\n isStopped: boolean;\n hasRunningBackgroundTasks: boolean;\n}\n\nconst normalizeAgentKey = (value: string): string => {\n return value\n .toLowerCase()\n .replace(/\\([^)]*\\)/g, \"\")\n .replace(/[^a-z0-9]/g, \"\");\n};\n\nconst isAgentSkipped = (skipAgents: string[], agent: string): boolean => {\n const target = normalizeAgentKey(agent);\n return skipAgents.some((item) => normalizeAgentKey(item) === target);\n};\n\nexport const evaluateIdleGuards = (input: GuardInput): GuardDecision => {\n const { state, snapshot, config, isStopped, hasRunningBackgroundTasks } =\n input;\n const now = config.now();\n\n if (state.isRecovering) {\n return { ok: false, reason: \"recovering\" };\n }\n\n if (\n config.guards.abortWindow &&\n state.abortDetectedAt &&\n now - state.abortDetectedAt < config.abortWindowMs\n ) {\n return { ok: false, reason: \"abort-window\" };\n }\n\n if (config.guards.backgroundTasks && hasRunningBackgroundTasks) {\n return { ok: false, reason: \"background-running\" };\n }\n\n if (snapshot.todos.length === 0) {\n return { ok: false, reason: \"todo-empty\" };\n }\n\n if (snapshot.incompleteCount === 0) {\n return { ok: false, reason: \"todo-complete\" };\n }\n\n if (state.inFlight) {\n return { ok: false, reason: \"in-flight\" };\n }\n\n if (\n state.consecutiveFailures >= config.maxConsecutiveFailures &&\n state.lastInjectedAt &&\n now - state.lastInjectedAt >= config.failureResetWindowMs\n ) {\n state.consecutiveFailures = 0;\n }\n\n if (state.consecutiveFailures >= config.maxConsecutiveFailures) {\n return { ok: false, reason: \"max-failures\" };\n }\n\n const effectiveCooldown =\n config.continuationCooldownMs *\n 2 ** Math.min(state.consecutiveFailures, config.maxConsecutiveFailures);\n\n if (state.lastInjectedAt && now - state.lastInjectedAt < effectiveCooldown) {\n return { ok: false, reason: \"cooldown\" };\n }\n\n if (\n config.guards.skippedAgents &&\n snapshot.resolvedInfo.agent &&\n isAgentSkipped(config.skipAgents, snapshot.resolvedInfo.agent)\n ) {\n return { ok: false, reason: \"skipped-agent\" };\n }\n\n if (config.guards.stopState && isStopped) {\n return { ok: false, reason: \"stop-state\" };\n }\n\n return { ok: true };\n};\n","import type { SessionState, TodoEnforcerConfig } from \"./types\";\n\ninterface StoredState {\n state: SessionState;\n touchedAt: number;\n}\n\nconst createInitialState = (): SessionState => {\n return {\n inFlight: false,\n isRecovering: false,\n consecutiveFailures: 0,\n };\n};\n\nexport interface SessionStateStore {\n get: (sessionID: string) => SessionState;\n touch: (sessionID: string) => void;\n clear: (sessionID: string) => void;\n clearAll: () => void;\n prune: () => void;\n}\n\nexport const createSessionStateStore = (\n config: TodoEnforcerConfig\n): SessionStateStore => {\n const sessions = new Map<string, StoredState>();\n let lastPruneAt = config.now();\n\n const clearTimers = (state: SessionState): void => {\n if (state.countdownTimer) {\n clearTimeout(state.countdownTimer);\n state.countdownTimer = undefined;\n }\n if (state.warningTimer) {\n clearTimeout(state.warningTimer);\n state.warningTimer = undefined;\n }\n state.countdownStartedAt = undefined;\n };\n\n const get = (sessionID: string): SessionState => {\n const existing = sessions.get(sessionID);\n if (existing) {\n existing.touchedAt = config.now();\n return existing.state;\n }\n\n const created = createInitialState();\n sessions.set(sessionID, { state: created, touchedAt: config.now() });\n return created;\n };\n\n const touch = (sessionID: string): void => {\n const existing = sessions.get(sessionID);\n if (existing) {\n existing.touchedAt = config.now();\n return;\n }\n get(sessionID);\n };\n\n const clear = (sessionID: string): void => {\n const existing = sessions.get(sessionID);\n if (!existing) {\n return;\n }\n clearTimers(existing.state);\n sessions.delete(sessionID);\n };\n\n const clearAll = (): void => {\n for (const entry of sessions.values()) {\n clearTimers(entry.state);\n }\n sessions.clear();\n };\n\n const prune = (): void => {\n const now = config.now();\n if (now - lastPruneAt < config.sessionPruneIntervalMs) {\n return;\n }\n lastPruneAt = now;\n\n for (const [sessionID, entry] of sessions.entries()) {\n if (now - entry.touchedAt <= config.sessionTtlMs) {\n continue;\n }\n clearTimers(entry.state);\n sessions.delete(sessionID);\n }\n };\n\n return {\n get,\n touch,\n clear,\n clearAll,\n prune,\n };\n};\n","import type { PluginInput } from \"@opencode-ai/plugin\";\nimport type { Event, Part, Todo } from \"@opencode-ai/sdk\";\n\nimport {\n isAbortLikeError,\n isLastAssistantMessageAborted,\n} from \"./abort-detection\";\nimport { injectContinuation } from \"./continuation\";\nimport { cancelCountdown, startCountdown } from \"./countdown\";\nimport { evaluateIdleGuards } from \"./guards\";\nimport { unwrapSdkResponse } from \"./response\";\nimport { createSessionStateStore } from \"./session-state\";\nimport { createTodoEnforcerTelemetry } from \"./telemetry\";\nimport { getIncompleteTodoCount } from \"./todo\";\nimport type {\n PromptMessage,\n SessionAgentInfo,\n TodoEnforcerConfig,\n} from \"./types\";\n\ninterface OrchestratorArgs {\n ctx: PluginInput;\n config: TodoEnforcerConfig;\n stopState: {\n isStopped: (sessionID: string) => boolean;\n setStopped: (sessionID: string, value: boolean) => void;\n clear: (sessionID: string) => void;\n };\n}\n\nconst NO_OP = (_error: unknown): undefined => {\n return undefined;\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => {\n return typeof value === \"object\" && value !== null;\n};\n\nconst extractSessionID = (event: Event): string | undefined => {\n if (!isRecord(event.properties)) {\n return undefined;\n }\n const properties = event.properties as Record<string, unknown>;\n\n const fromProperties = properties.sessionID;\n if (typeof fromProperties === \"string\") {\n return fromProperties;\n }\n\n const info = properties.info;\n if (!isRecord(info)) {\n return undefined;\n }\n\n const fromInfo = info.sessionID ?? info.id;\n return typeof fromInfo === \"string\" ? fromInfo : undefined;\n};\n\nconst extractResolvedInfo = (messages: PromptMessage[]): SessionAgentInfo => {\n let sawCompaction = false;\n\n for (let index = messages.length - 1; index >= 0; index -= 1) {\n const info = messages[index].info;\n if (!info) {\n continue;\n }\n\n if (info.agent?.toLowerCase() === \"compaction\") {\n sawCompaction = true;\n continue;\n }\n\n if (info.agent || info.model) {\n return {\n agent: info.agent,\n model: info.model,\n };\n }\n\n if (info.providerID && info.modelID) {\n return {\n agent: info.agent,\n model: {\n providerID: info.providerID,\n modelID: info.modelID,\n },\n };\n }\n }\n\n if (sawCompaction) {\n return { agent: \"compaction\" };\n }\n\n return {};\n};\n\nconst extractMessageRole = (event: Event): string | undefined => {\n if (!isRecord(event.properties)) {\n return undefined;\n }\n const properties = event.properties as Record<string, unknown>;\n\n const info = properties.info;\n if (!isRecord(info)) {\n return undefined;\n }\n\n return typeof info.role === \"string\" ? info.role : undefined;\n};\n\nconst extractUserText = (parts: Part[]): string => {\n const textParts = parts.filter(\n (part): part is Extract<Part, { type: \"text\" }> => {\n return part.type === \"text\";\n }\n );\n const chunks: string[] = [];\n for (const part of textParts) {\n chunks.push(part.text);\n }\n return chunks.join(\"\\n\").trim();\n};\n\nexport const createTodoEnforcerOrchestrator = ({\n ctx,\n config,\n stopState,\n}: OrchestratorArgs) => {\n const states = createSessionStateStore(config);\n const telemetry = createTodoEnforcerTelemetry();\n\n const cancelForActivity = (sessionID: string): void => {\n const state = states.get(sessionID);\n const hadCountdown = Boolean(state.countdownStartedAt);\n if (state.countdownStartedAt) {\n telemetry.log({\n kind: \"countdown_cancelled\",\n sessionID,\n reason: \"activity\",\n });\n }\n cancelCountdown(state);\n if (hadCountdown) {\n state.userActivityAt = config.now();\n }\n states.touch(sessionID);\n };\n\n const cancelCountdownForSession = (sessionID: string): void => {\n const state = states.get(sessionID);\n const hadCountdown = Boolean(state.countdownStartedAt);\n if (state.countdownStartedAt) {\n telemetry.log({\n kind: \"countdown_cancelled\",\n sessionID,\n reason: \"session_event\",\n });\n }\n cancelCountdown(state);\n if (hadCountdown) {\n state.userActivityAt = config.now();\n }\n states.touch(sessionID);\n };\n\n const handleIdle = async (sessionID: string): Promise<void> => {\n const state = states.get(sessionID);\n states.prune();\n telemetry.log({ kind: \"idle_seen\", sessionID });\n\n if (\n state.userActivityAt &&\n config.now() - state.userActivityAt < config.countdownGraceMs\n ) {\n telemetry.log({\n kind: \"idle_skipped\",\n sessionID,\n reason: \"post-cancel-grace\",\n });\n return;\n }\n\n let todos: Todo[] = [];\n try {\n const todoResponse = await ctx.client.session.todo({\n path: { id: sessionID },\n });\n todos = unwrapSdkResponse(todoResponse, [] as Todo[]);\n } catch (_error) {\n return;\n }\n\n let messages: PromptMessage[] = [];\n try {\n const messageResponse = await ctx.client.session.messages({\n path: { id: sessionID },\n query: { directory: ctx.directory },\n });\n messages = unwrapSdkResponse(messageResponse, [] as PromptMessage[]);\n } catch (_error) {\n return;\n }\n\n if (isLastAssistantMessageAborted(messages)) {\n state.abortDetectedAt = config.now();\n }\n\n const resolvedInfo = extractResolvedInfo(messages);\n const snapshot = {\n todos,\n incompleteCount: getIncompleteTodoCount(todos),\n resolvedInfo,\n };\n\n const decision = evaluateIdleGuards({\n state,\n snapshot,\n config,\n isStopped: stopState.isStopped(sessionID),\n hasRunningBackgroundTasks:\n config.hasRunningBackgroundTasks?.(sessionID) ?? false,\n });\n\n if (!decision.ok) {\n telemetry.log({\n kind: \"idle_skipped\",\n sessionID,\n reason: decision.reason,\n });\n return;\n }\n\n telemetry.log({ kind: \"countdown_started\", sessionID });\n startCountdown({\n ctx,\n config,\n state,\n sessionID,\n incompleteCount: snapshot.incompleteCount,\n onElapsed: async () => {\n const stateAfterCountdown = states.get(sessionID);\n if (\n stateAfterCountdown.userActivityAt &&\n config.now() - stateAfterCountdown.userActivityAt <\n config.countdownGraceMs\n ) {\n return;\n }\n\n const result = await injectContinuation({\n ctx,\n config,\n sessionID,\n state: stateAfterCountdown,\n resolvedInfo,\n });\n\n telemetry.log({\n kind: result.status === \"injected\" ? \"injected\" : \"injection_skipped\",\n sessionID,\n reason: result.status,\n });\n },\n });\n };\n\n const handleSessionError = (sessionID: string, event: Event): void => {\n const state = states.get(sessionID);\n if (\n event.type === \"session.error\" &&\n isAbortLikeError(event.properties.error)\n ) {\n state.abortDetectedAt = config.now();\n telemetry.log({ kind: \"abort_detected\", sessionID });\n } else {\n state.abortDetectedAt = undefined;\n telemetry.log({ kind: \"non_abort_error\", sessionID });\n }\n cancelCountdownForSession(sessionID);\n };\n\n const handleCommandExecuted = (sessionID: string, event: Event): void => {\n if (event.type !== \"command.executed\" || !isRecord(event.properties)) {\n return;\n }\n\n if (event.properties.name === config.stopCommand.replace(\"/\", \"\")) {\n stopState.setStopped(sessionID, true);\n telemetry.log({ kind: \"stop_set_command\", sessionID });\n }\n };\n\n const shouldIgnoreUserActivity = (\n sessionID: string,\n event: Event\n ): boolean => {\n const role = extractMessageRole(event);\n if (role !== \"user\") {\n return false;\n }\n\n const state = states.get(sessionID);\n return Boolean(\n state.countdownStartedAt &&\n config.now() - state.countdownStartedAt < config.countdownGraceMs\n );\n };\n\n const onEvent = async (input: { event: Event }): Promise<void> => {\n if (!config.enabled) {\n return;\n }\n\n const { event } = input;\n const sessionID = extractSessionID(event);\n if (!sessionID) {\n return;\n }\n\n switch (event.type) {\n case \"session.idle\": {\n await handleIdle(sessionID);\n return;\n }\n case \"session.deleted\": {\n states.clear(sessionID);\n stopState.clear(sessionID);\n telemetry.log({ kind: \"session_deleted\", sessionID });\n return;\n }\n case \"session.error\": {\n handleSessionError(sessionID, event);\n return;\n }\n case \"command.executed\": {\n handleCommandExecuted(sessionID, event);\n return;\n }\n case \"message.updated\": {\n if (shouldIgnoreUserActivity(sessionID, event)) {\n return;\n }\n cancelForActivity(sessionID);\n return;\n }\n case \"message.part.updated\":\n case \"session.status\": {\n cancelForActivity(sessionID);\n return;\n }\n default: {\n return;\n }\n }\n };\n\n const onChatMessage = async (\n input: { sessionID: string },\n output: { parts: Part[] }\n ): Promise<void> => {\n if (!config.enabled) {\n return;\n }\n\n const text = extractUserText(output.parts);\n telemetry.log({ kind: \"chat_message_seen\", sessionID: input.sessionID });\n if (text === config.stopCommand) {\n stopState.setStopped(input.sessionID, true);\n telemetry.log({ kind: \"stop_set_chat\", sessionID: input.sessionID });\n await ctx.client.tui\n .showToast({\n body: {\n variant: \"warning\",\n message: \"Todo continuation paused for this session.\",\n },\n })\n .catch(NO_OP);\n return;\n }\n\n if (stopState.isStopped(input.sessionID)) {\n stopState.setStopped(input.sessionID, false);\n telemetry.log({ kind: \"stop_cleared_chat\", sessionID: input.sessionID });\n await ctx.client.tui\n .showToast({\n body: {\n variant: \"info\",\n message: \"Todo continuation resumed for this session.\",\n },\n })\n .catch(NO_OP);\n }\n };\n\n const onToolExecuteBefore = (input: { sessionID: string }): void => {\n if (!config.enabled) {\n return;\n }\n cancelForActivity(input.sessionID);\n };\n\n const onToolExecuteAfter = (input: { sessionID: string }): void => {\n if (!config.enabled) {\n return;\n }\n cancelForActivity(input.sessionID);\n };\n\n return {\n onEvent,\n onChatMessage,\n onToolExecuteBefore,\n onToolExecuteAfter,\n };\n};\n","export interface StopStateStore {\n isStopped: (sessionID: string) => boolean;\n setStopped: (sessionID: string, value: boolean) => void;\n clear: (sessionID: string) => void;\n}\n\nexport const createStopStateStore = (): StopStateStore => {\n const stoppedSessions = new Set<string>();\n\n return {\n isStopped: (sessionID: string): boolean => stoppedSessions.has(sessionID),\n setStopped: (sessionID: string, value: boolean): void => {\n if (value) {\n stoppedSessions.add(sessionID);\n return;\n }\n stoppedSessions.delete(sessionID);\n },\n clear: (sessionID: string): void => {\n stoppedSessions.delete(sessionID);\n },\n };\n};\n","import type { Plugin } from \"@opencode-ai/plugin\";\n\nimport {\n createTodoEnforcerConfig,\n type TodoEnforcerOptions,\n} from \"./todo-enforcer/config\";\nimport { todoEnforcerDebugPingTool } from \"./todo-enforcer/debug-tool\";\nimport { createTodoEnforcerOrchestrator } from \"./todo-enforcer/orchestrator\";\nimport { createStopStateStore } from \"./todo-enforcer/stop-state\";\n\nexport type { TodoEnforcerOptions } from \"./todo-enforcer/config\";\n\nexport const TodoEnforcerPlugin: Plugin = (input) => {\n const config = createTodoEnforcerConfig();\n const stopState = createStopStateStore();\n const orchestrator = createTodoEnforcerOrchestrator({\n ctx: input,\n config,\n stopState,\n });\n\n return Promise.resolve({\n tool: {\n todo_enforcer_debug_ping: todoEnforcerDebugPingTool,\n },\n event: orchestrator.onEvent,\n \"chat.message\": async (payload, output) => {\n await orchestrator.onChatMessage(payload, output);\n },\n \"tool.execute.before\": async (payload) => {\n await orchestrator.onToolExecuteBefore(payload);\n },\n \"tool.execute.after\": async (payload) => {\n await orchestrator.onToolExecuteAfter(payload);\n },\n });\n};\n\nexport const createTodoEnforcerPlugin = (\n options?: TodoEnforcerOptions\n): Plugin => {\n return (input) => {\n const config = createTodoEnforcerConfig(options);\n const stopState = createStopStateStore();\n const orchestrator = createTodoEnforcerOrchestrator({\n ctx: input,\n config,\n stopState,\n });\n\n return Promise.resolve({\n tool: {\n todo_enforcer_debug_ping: todoEnforcerDebugPingTool,\n },\n event: orchestrator.onEvent,\n \"chat.message\": async (payload, output) => {\n await orchestrator.onChatMessage(payload, output);\n },\n \"tool.execute.before\": async (payload) => {\n await orchestrator.onToolExecuteBefore(payload);\n },\n \"tool.execute.after\": async (payload) => {\n await orchestrator.onToolExecuteAfter(payload);\n },\n });\n };\n};\n\nexport default TodoEnforcerPlugin;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Todo Enforcer Parity Notes
|
|
2
|
+
|
|
3
|
+
This standalone implementation mirrors the upstream `todo-continuation-enforcer` behavior where possible without importing internal `oh-my-opencode` modules.
|
|
4
|
+
|
|
5
|
+
## Parity maintained
|
|
6
|
+
|
|
7
|
+
- Idle-triggered continuation flow (`session.idle` only)
|
|
8
|
+
- Guard order for recovery/abort/background/todo/in-flight/failure/cooldown/skip/stop
|
|
9
|
+
- Exponential cooldown based on consecutive failures
|
|
10
|
+
- Countdown-based delayed continuation injection
|
|
11
|
+
- Cancellation on non-idle activity (`message.updated`, `message.part.updated`, tool lifecycle, status changes)
|
|
12
|
+
- Per-session stop control with `/stop-continuation`
|
|
13
|
+
|
|
14
|
+
## Intentional differences
|
|
15
|
+
|
|
16
|
+
- No internal background manager dependency; background activity check is optional via `hasRunningBackgroundTasks`
|
|
17
|
+
- No upstream shared helper imports (response normalization, system directives, storage detection)
|
|
18
|
+
- Stop command is implemented through chat/event interception rather than built-in command registration
|
|
19
|
+
|
|
20
|
+
## Safety controls
|
|
21
|
+
|
|
22
|
+
- Per-session state store with TTL pruning
|
|
23
|
+
- Consecutive failure cap and reset window
|
|
24
|
+
- Abort window suppression after detected abort-like assistant errors
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
export interface TodoEnforcerOptions {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
prompt?: string;
|
|
6
|
+
stopCommand?: string;
|
|
7
|
+
skipAgents?: string[];
|
|
8
|
+
countdownMs?: number;
|
|
9
|
+
countdownGraceMs?: number;
|
|
10
|
+
continuationCooldownMs?: number;
|
|
11
|
+
abortWindowMs?: number;
|
|
12
|
+
failureResetWindowMs?: number;
|
|
13
|
+
maxConsecutiveFailures?: number;
|
|
14
|
+
sessionTtlMs?: number;
|
|
15
|
+
sessionPruneIntervalMs?: number;
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
guards?: {
|
|
18
|
+
abortWindow?: boolean;
|
|
19
|
+
backgroundTasks?: boolean;
|
|
20
|
+
skippedAgents?: boolean;
|
|
21
|
+
stopState?: boolean;
|
|
22
|
+
};
|
|
23
|
+
hasRunningBackgroundTasks?: (sessionID: string) => boolean;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare const TodoEnforcerPlugin: Plugin;
|
|
28
|
+
export declare const createTodoEnforcerPlugin: (
|
|
29
|
+
options?: TodoEnforcerOptions
|
|
30
|
+
) => Plugin;
|
|
31
|
+
|
|
32
|
+
export default TodoEnforcerPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-todo-enforcer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone OpenCode plugin that enforces todo continuation on idle sessions",
|
|
5
|
+
"packageManager": "bun@1.2.22",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.mjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"default": "./dist/index.mjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"index.d.ts",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"docs"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "bunx tsdown --watch",
|
|
25
|
+
"build": "bunx tsdown --fail-on-warn",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"test:integration": "bun run scripts/integration-test.ts",
|
|
29
|
+
"test:e2e": "bun run scripts/e2e-opencode-run.ts",
|
|
30
|
+
"test:e2e:npm": "bun run scripts/e2e-opencode-run.ts --mode npm",
|
|
31
|
+
"opencode:local": "opencode",
|
|
32
|
+
"opencode:local:config": "opencode debug config",
|
|
33
|
+
"opencode:npm": "bun run scripts/run-opencode-npm.ts",
|
|
34
|
+
"opencode:npm:config": "bun run scripts/run-opencode-npm.ts debug config",
|
|
35
|
+
"lint": "ultracite check --error-on-warnings",
|
|
36
|
+
"check": "bun run lint && bun run typecheck && bun run test && bun run test:integration && bun run build",
|
|
37
|
+
"release:verify": "bun run check && npm pack --dry-run",
|
|
38
|
+
"release:patch": "npm version patch -m \"chore(release): v%s\"",
|
|
39
|
+
"release:minor": "npm version minor -m \"chore(release): v%s\"",
|
|
40
|
+
"release:major": "npm version major -m \"chore(release): v%s\"",
|
|
41
|
+
"release:beta:first": "npm version prepatch --preid beta -m \"chore(release): v%s\"",
|
|
42
|
+
"release:beta:next": "npm version prerelease --preid beta -m \"chore(release): v%s\"",
|
|
43
|
+
"prepare": "husky",
|
|
44
|
+
"prepack": "bun run build",
|
|
45
|
+
"fix": "ultracite fix --unsafe"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"opencode",
|
|
49
|
+
"plugin",
|
|
50
|
+
"todo",
|
|
51
|
+
"enforcer"
|
|
52
|
+
],
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/Aureatus/opencode-todo-enforcer.git"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/Aureatus/opencode-todo-enforcer",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/Aureatus/opencode-todo-enforcer/issues"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"typescript": "^5.0.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@opencode-ai/plugin": "^1.0.150"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@biomejs/biome": "2.3.13",
|
|
70
|
+
"@types/bun": "latest",
|
|
71
|
+
"husky": "^9.1.7",
|
|
72
|
+
"tsdown": "^0.20.3",
|
|
73
|
+
"typescript": "^5.9.3",
|
|
74
|
+
"ultracite": "7.1.4"
|
|
75
|
+
}
|
|
76
|
+
}
|