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 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.
@@ -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":";;;;;;;AAEA,MAAa,sBAAsB;CACjC;CACA;CACA;CACD,CAAC,KAAK,IAAI;AAEX,MAAa,4BAA4B;AAEzC,MAAa,uBAAuB;AACpC,MAAa,sBAAsB;AACnC,MAAa,0BAA0B;AACvC,MAAa,kCAAkC;AAC/C,MAAa,mCAAmC;AAChD,MAAa,6BAA6B;AAC1C,MAAa,yBAAyB,MAAU;AAChD,MAAa,4BAA4B,MAAS;AAElD,MAAa,sBAAsB,CAAC,cAAc,aAAa;AAC/D,MAAa,4BAA4B;;;;ACmBzC,MAAM,mBAA2B,KAAK,KAAK;AAE3C,MAAM,YAAY,SAAqC;CACrD,MAAM,QAAQ,QAAQ,IAAI,OAAO,MAAM;AACvC,QAAO,SAAS,MAAM,SAAS,IAAI,QAAQ;;AAG7C,MAAa,4BACX,YACuB;AACvB,QAAO;EACL,SAAS,SAAS,WAAW;EAC7B,QAAQ,SAAS,UAAU;EAC3B,aACE,SAAS,eACT,SAAS,sCAAsC,IAC/C;EACF,YAAY,SAAS,cAAc,CAAC,GAAG,oBAAoB;EAC3D,aAAa,SAAS,eAAe;EACrC,kBAAkB,SAAS,oBAAoB;EAC/C,wBACE,SAAS,0BAA0B;EACrC,eAAe,SAAS,iBAAiB;EACzC,sBACE,SAAS,wBAAwB;EACnC,wBACE,SAAS,0BAA0B;EACrC,cAAc,SAAS,gBAAgB;EACvC,wBACE,SAAS,0BAA0B;EACrC,OAAO,SAAS,SAAS;EACzB,QAAQ;GACN,aAAa,SAAS,QAAQ,eAAe;GAC7C,iBAAiB,SAAS,QAAQ,mBAAmB;GACrD,eAAe,SAAS,QAAQ,iBAAiB;GACjD,WAAW,SAAS,QAAQ,aAAa;GAC1C;EACD,2BAA2B,SAAS;EACpC,KAAK,SAAS,OAAO;EACtB;;;;;ACzDH,MAAM,6BAAqC;AACzC,QAAO,KAAK,KACV,GAAG,SAAS,EACZ,UACA,SACA,YACA,WACA,0BACA,kBACD;;AAGH,MAAMA,kBAAgB,WAA+B;AAIrD,MAAM,6BAAqC;CACzC,MAAM,aAAa,QAAQ,IAAI,uCAAuC,MAAM;AAC5E,KAAI,WACF,QAAO;AAET,QAAO,sBAAsB;;AAG/B,MAAa,oCAER;CACH,MAAM,WAAW,QAAQ,IAAI,qCAAqC;CAClE,MAAM,gBAAgB,sBAAsB;CAC5C,MAAM,UAAU,QAAQ,IAAI,0CAA0C,MAAM;CAE5E,IAAI,cAAc;CAElB,MAAM,OAAO,UAAqC;AAChD,MAAI,SACF;EAGF,MAAM,UAAgC;GACpC,OAAO;GACP,MAAM,MAAM;GACZ,YAAY,MAAM;GAClB,QAAQ,MAAM;GACd,UAAU,MAAM;GAChB;GACA,WAAW,KAAK,KAAK;GACtB;AAED,MAAI;AACF,OAAI,CAAC,aAAa;AAChB,cAAU,KAAK,QAAQ,cAAc,EAAE,EACrC,WAAW,MACZ,CAAC;AACF,kBAAc;;AAGhB,kBAAe,eAAe,GAAG,KAAK,UAAU,QAAQ,CAAC,KAAK,OAAO;WAC9D,OAAO;AACd,kBAAa,MAAM;;;AAIvB,QAAO,EAAE,KAAK;;;;;AC5EhB,MAAa,sBAAsB;AAEnC,MAAa,4BAA4B,cAA8B;AACrE,QAAO,KAAK,KAAK,WAAW,oBAAoB;;AAGlD,MAAM,YAAY,6BAA6B;AAE/C,MAAa,4BAA4B,KAAK;CAC5C,aACE;CACF,MAAM,EACJ,QAAQ,KAAK,OACV,QAAQ,CACR,UAAU,CACV,SAAS,yDAAyD,EACtE;CACD,SAAS,OAAO,MAAM,YAAY;EAChC,MAAM,SAAS,KAAK,QAAQ,MAAM,IAAI;EACtC,MAAM,WAAW,yBAAyB,QAAQ,UAAU;EAC5D,MAAM,UAAU;GACd,OAAO;GACP;GACA,WAAW,QAAQ;GACnB,WAAW,QAAQ;GACnB,OAAO,QAAQ;GACf,WAAW,QAAQ;GACnB,UAAU,QAAQ;GAClB,WAAW,KAAK,KAAK;GACtB;AAED,QAAM,MAAM,KAAK,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;AACxD,QAAM,WAAW,UAAU,GAAG,KAAK,UAAU,QAAQ,CAAC,KAAK,OAAO;AAElE,YAAU,IAAI;GACZ,MAAM;GACN,WAAW,QAAQ;GACnB,UAAU;IACR;IACA,WAAW;IACZ;GACF,CAAC;AAEF,SAAO,KAAK,UACV;GACE,IAAI;GACJ;GACA,WAAW;GACZ,EACD,MACA,EACD;;CAEJ,CAAC;;;;AC1DF,MAAM,iBAAiB;CAAC;CAAW;CAAS;CAAa;CAAW;AAEpE,MAAa,wBAAwB,UAAuC;AAC1E,KAAI,CAAC,MACH,QAAO;CAET,MAAM,aAAa,MAAM,aAAa;AACtC,QAAO,eAAe,MAAM,YAAY,WAAW,SAAS,QAAQ,CAAC;;AAGvE,MAAa,oBAAoB,UAA4B;AAC3D,KAAI,OAAO,UAAU,YAAY,UAAU,KACzC,QAAO;CAGT,MAAM,aAAa;AAMnB,QACE,qBAAqB,WAAW,KAAK,IACrC,qBAAqB,WAAW,KAAK,IACrC,qBAAqB,WAAW,QAAQ;;AAI5C,MAAa,iCACX,aACY;AACZ,MAAK,IAAI,QAAQ,SAAS,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;EAC5D,MAAM,OAAO,SAAS,QAAQ;AAC9B,MAAI,CAAC,QAAQ,KAAK,SAAS,YACzB;AAGF,MAAI,qBAAqB,KAAK,OAAO,KAAK,CACxC,QAAO;AAET,MAAI,qBAAqB,KAAK,OAAO,KAAK,CACxC,QAAO;AAET,MAAI,qBAAqB,KAAK,OAAO,QAAQ,CAC3C,QAAO;AAGT,SAAO;;AAGT,QAAO;;;;;ACpDT,MAAMC,cAAY,UAAqD;AACrE,QAAO,OAAO,UAAU,YAAY,UAAU;;AAGhD,MAAa,qBAAwB,OAAgB,aAAmB;AACtE,KAAI,CAACA,WAAS,MAAM,CAClB,QAAO;CAGT,MAAM,OAAO,MAAM;AACnB,KAAI,SAAS,OACX,QAAO;AAGT,QAAO;;;;;ACZT,MAAM,oBAAoB,IAAI,IAAI,CAAC,aAAa,YAAY,CAAC;AAE7D,MAAa,0BAA0B,UAA0B;CAC/D,IAAI,QAAQ;AACZ,MAAK,MAAM,QAAQ,MACjB,KAAI,CAAC,kBAAkB,IAAI,KAAK,OAAO,CACrC,UAAS;AAGb,QAAO;;;;;ACCT,MAAMC,kBAAgB,WAA+B;AASrD,MAAa,qBAAqB,OAAO,SAMN;CACjC,MAAM,EAAE,KAAK,QAAQ,WAAW,OAAO,iBAAiB;AAExD,KAAI;AAMF,MADwB,uBADV,kBAHO,MAAM,IAAI,OAAO,QAAQ,KAAK,EACjD,MAAM,EAAE,IAAI,WAAW,EACxB,CAAC,EAC4C,EAAE,CAAW,CACN,KAC7B,EACtB,QAAO,EAAE,QAAQ,oBAAoB;AAGvC,QAAM,WAAW;AACjB,QAAM,IAAI,OAAO,QAAQ,YAAY;GACnC,MAAM,EAAE,IAAI,WAAW;GACvB,OAAO,EAAE,WAAW,IAAI,WAAW;GACnC,MAAM;IACJ,OAAO,aAAa;IACpB,OAAO,aAAa;IACpB,OAAO,CACL;KACE,MAAM;KACN,MAAM,GAAG,OAAO,OAAO,KAAK,0BAA0B;KACtD,UAAU,GACP,4BAA4B,MAC9B;KACF,CACF;IACF;GACF,CAAC;AAEF,QAAM,iBAAiB,OAAO,KAAK;AACnC,QAAM,sBAAsB;AAC5B,SAAO,EAAE,QAAQ,YAAY;UACtB,QAAQ;AACf,QAAM,iBAAiB,OAAO,KAAK;AACnC,QAAM,uBAAuB;AAC7B,QAAM,IAAI,OAAO,IACd,UAAU,EACT,MAAM;GACJ,SAAS;GACT,SAAS,yCAAyC,UAAU;GAC7D,EACF,CAAC,CACD,MAAMA,eAAa;AACtB,SAAO,EAAE,QAAQ,UAAU;WACnB;AACR,QAAM,WAAW;;;;;;ACvErB,MAAa,mBAAmB,UAA8B;AAC5D,KAAI,MAAM,gBAAgB;AACxB,eAAa,MAAM,eAAe;AAClC,QAAM,iBAAiB;;AAEzB,KAAI,MAAM,cAAc;AACtB,eAAa,MAAM,aAAa;AAChC,QAAM,eAAe;;AAEvB,OAAM,qBAAqB;;AAG7B,MAAM,gBAAgB,WAA+B;AAIrD,MAAa,kBAAkB,SAOnB;CACV,MAAM,EAAE,KAAK,QAAQ,OAAO,WAAW,iBAAiB,cAAc;AAEtE,iBAAgB,MAAM;AACtB,OAAM,qBAAqB,OAAO,KAAK;AAEvC,KAAI,OAAO,IACR,UAAU,EACT,MAAM;EACJ,SAAS;EACT,SAAS,gCAAgC,OAAO,cAAc,KAAM,QAAQ,EAAE,CAAC,KAAK,gBAAgB;EACrG,EACF,CAAC,CACD,MAAM,aAAa;AAEtB,OAAM,eAAe,iBACb;AACJ,MAAI,OAAO,IACR,UAAU,EACT,MAAM;GACJ,SAAS;GACT,SAAS,4CAA4C,UAAU;GAChE,EACF,CAAC,CACD,MAAM,aAAa;IAExB,KAAK,IAAI,GAAG,OAAO,cAAc,IAAK,CACvC;AAED,OAAM,iBAAiB,iBAAiB;AACtC,QAAM,iBAAiB;AACvB,QAAM,eAAe;AACrB,QAAM,qBAAqB;AAC3B,aAAW,CAAC,MAAM,aAAa;IAC9B,OAAO,YAAY;;;;;AClCxB,MAAM,qBAAqB,UAA0B;AACnD,QAAO,MACJ,aAAa,CACb,QAAQ,cAAc,GAAG,CACzB,QAAQ,cAAc,GAAG;;AAG9B,MAAM,kBAAkB,YAAsB,UAA2B;CACvE,MAAM,SAAS,kBAAkB,MAAM;AACvC,QAAO,WAAW,MAAM,SAAS,kBAAkB,KAAK,KAAK,OAAO;;AAGtE,MAAa,sBAAsB,UAAqC;CACtE,MAAM,EAAE,OAAO,UAAU,QAAQ,WAAW,8BAC1C;CACF,MAAM,MAAM,OAAO,KAAK;AAExB,KAAI,MAAM,aACR,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAc;AAG5C,KACE,OAAO,OAAO,eACd,MAAM,mBACN,MAAM,MAAM,kBAAkB,OAAO,cAErC,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAgB;AAG9C,KAAI,OAAO,OAAO,mBAAmB,0BACnC,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAsB;AAGpD,KAAI,SAAS,MAAM,WAAW,EAC5B,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAc;AAG5C,KAAI,SAAS,oBAAoB,EAC/B,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAiB;AAG/C,KAAI,MAAM,SACR,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAa;AAG3C,KACE,MAAM,uBAAuB,OAAO,0BACpC,MAAM,kBACN,MAAM,MAAM,kBAAkB,OAAO,qBAErC,OAAM,sBAAsB;AAG9B,KAAI,MAAM,uBAAuB,OAAO,uBACtC,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAgB;CAG9C,MAAM,oBACJ,OAAO,yBACP,KAAK,KAAK,IAAI,MAAM,qBAAqB,OAAO,uBAAuB;AAEzE,KAAI,MAAM,kBAAkB,MAAM,MAAM,iBAAiB,kBACvD,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAY;AAG1C,KACE,OAAO,OAAO,iBACd,SAAS,aAAa,SACtB,eAAe,OAAO,YAAY,SAAS,aAAa,MAAM,CAE9D,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAiB;AAG/C,KAAI,OAAO,OAAO,aAAa,UAC7B,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAc;AAG5C,QAAO,EAAE,IAAI,MAAM;;;;;ACjGrB,MAAM,2BAAyC;AAC7C,QAAO;EACL,UAAU;EACV,cAAc;EACd,qBAAqB;EACtB;;AAWH,MAAa,2BACX,WACsB;CACtB,MAAM,2BAAW,IAAI,KAA0B;CAC/C,IAAI,cAAc,OAAO,KAAK;CAE9B,MAAM,eAAe,UAA8B;AACjD,MAAI,MAAM,gBAAgB;AACxB,gBAAa,MAAM,eAAe;AAClC,SAAM,iBAAiB;;AAEzB,MAAI,MAAM,cAAc;AACtB,gBAAa,MAAM,aAAa;AAChC,SAAM,eAAe;;AAEvB,QAAM,qBAAqB;;CAG7B,MAAM,OAAO,cAAoC;EAC/C,MAAM,WAAW,SAAS,IAAI,UAAU;AACxC,MAAI,UAAU;AACZ,YAAS,YAAY,OAAO,KAAK;AACjC,UAAO,SAAS;;EAGlB,MAAM,UAAU,oBAAoB;AACpC,WAAS,IAAI,WAAW;GAAE,OAAO;GAAS,WAAW,OAAO,KAAK;GAAE,CAAC;AACpE,SAAO;;CAGT,MAAM,SAAS,cAA4B;EACzC,MAAM,WAAW,SAAS,IAAI,UAAU;AACxC,MAAI,UAAU;AACZ,YAAS,YAAY,OAAO,KAAK;AACjC;;AAEF,MAAI,UAAU;;CAGhB,MAAM,SAAS,cAA4B;EACzC,MAAM,WAAW,SAAS,IAAI,UAAU;AACxC,MAAI,CAAC,SACH;AAEF,cAAY,SAAS,MAAM;AAC3B,WAAS,OAAO,UAAU;;CAG5B,MAAM,iBAAuB;AAC3B,OAAK,MAAM,SAAS,SAAS,QAAQ,CACnC,aAAY,MAAM,MAAM;AAE1B,WAAS,OAAO;;CAGlB,MAAM,cAAoB;EACxB,MAAM,MAAM,OAAO,KAAK;AACxB,MAAI,MAAM,cAAc,OAAO,uBAC7B;AAEF,gBAAc;AAEd,OAAK,MAAM,CAAC,WAAW,UAAU,SAAS,SAAS,EAAE;AACnD,OAAI,MAAM,MAAM,aAAa,OAAO,aAClC;AAEF,eAAY,MAAM,MAAM;AACxB,YAAS,OAAO,UAAU;;;AAI9B,QAAO;EACL;EACA;EACA;EACA;EACA;EACD;;;;;ACtEH,MAAM,SAAS,WAA+B;AAI9C,MAAM,YAAY,UAAqD;AACrE,QAAO,OAAO,UAAU,YAAY,UAAU;;AAGhD,MAAM,oBAAoB,UAAqC;AAC7D,KAAI,CAAC,SAAS,MAAM,WAAW,CAC7B;CAEF,MAAM,aAAa,MAAM;CAEzB,MAAM,iBAAiB,WAAW;AAClC,KAAI,OAAO,mBAAmB,SAC5B,QAAO;CAGT,MAAM,OAAO,WAAW;AACxB,KAAI,CAAC,SAAS,KAAK,CACjB;CAGF,MAAM,WAAW,KAAK,aAAa,KAAK;AACxC,QAAO,OAAO,aAAa,WAAW,WAAW;;AAGnD,MAAM,uBAAuB,aAAgD;CAC3E,IAAI,gBAAgB;AAEpB,MAAK,IAAI,QAAQ,SAAS,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;EAC5D,MAAM,OAAO,SAAS,OAAO;AAC7B,MAAI,CAAC,KACH;AAGF,MAAI,KAAK,OAAO,aAAa,KAAK,cAAc;AAC9C,mBAAgB;AAChB;;AAGF,MAAI,KAAK,SAAS,KAAK,MACrB,QAAO;GACL,OAAO,KAAK;GACZ,OAAO,KAAK;GACb;AAGH,MAAI,KAAK,cAAc,KAAK,QAC1B,QAAO;GACL,OAAO,KAAK;GACZ,OAAO;IACL,YAAY,KAAK;IACjB,SAAS,KAAK;IACf;GACF;;AAIL,KAAI,cACF,QAAO,EAAE,OAAO,cAAc;AAGhC,QAAO,EAAE;;AAGX,MAAM,sBAAsB,UAAqC;AAC/D,KAAI,CAAC,SAAS,MAAM,WAAW,CAC7B;CAIF,MAAM,OAFa,MAAM,WAED;AACxB,KAAI,CAAC,SAAS,KAAK,CACjB;AAGF,QAAO,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;;AAGrD,MAAM,mBAAmB,UAA0B;CACjD,MAAM,YAAY,MAAM,QACrB,SAAkD;AACjD,SAAO,KAAK,SAAS;GAExB;CACD,MAAM,SAAmB,EAAE;AAC3B,MAAK,MAAM,QAAQ,UACjB,QAAO,KAAK,KAAK,KAAK;AAExB,QAAO,OAAO,KAAK,KAAK,CAAC,MAAM;;AAGjC,MAAa,kCAAkC,EAC7C,KACA,QACA,gBACsB;CACtB,MAAM,SAAS,wBAAwB,OAAO;CAC9C,MAAM,YAAY,6BAA6B;CAE/C,MAAM,qBAAqB,cAA4B;EACrD,MAAM,QAAQ,OAAO,IAAI,UAAU;EACnC,MAAM,eAAe,QAAQ,MAAM,mBAAmB;AACtD,MAAI,MAAM,mBACR,WAAU,IAAI;GACZ,MAAM;GACN;GACA,QAAQ;GACT,CAAC;AAEJ,kBAAgB,MAAM;AACtB,MAAI,aACF,OAAM,iBAAiB,OAAO,KAAK;AAErC,SAAO,MAAM,UAAU;;CAGzB,MAAM,6BAA6B,cAA4B;EAC7D,MAAM,QAAQ,OAAO,IAAI,UAAU;EACnC,MAAM,eAAe,QAAQ,MAAM,mBAAmB;AACtD,MAAI,MAAM,mBACR,WAAU,IAAI;GACZ,MAAM;GACN;GACA,QAAQ;GACT,CAAC;AAEJ,kBAAgB,MAAM;AACtB,MAAI,aACF,OAAM,iBAAiB,OAAO,KAAK;AAErC,SAAO,MAAM,UAAU;;CAGzB,MAAM,aAAa,OAAO,cAAqC;EAC7D,MAAM,QAAQ,OAAO,IAAI,UAAU;AACnC,SAAO,OAAO;AACd,YAAU,IAAI;GAAE,MAAM;GAAa;GAAW,CAAC;AAE/C,MACE,MAAM,kBACN,OAAO,KAAK,GAAG,MAAM,iBAAiB,OAAO,kBAC7C;AACA,aAAU,IAAI;IACZ,MAAM;IACN;IACA,QAAQ;IACT,CAAC;AACF;;EAGF,IAAI,QAAgB,EAAE;AACtB,MAAI;AAIF,WAAQ,kBAHa,MAAM,IAAI,OAAO,QAAQ,KAAK,EACjD,MAAM,EAAE,IAAI,WAAW,EACxB,CAAC,EACsC,EAAE,CAAW;WAC9C,QAAQ;AACf;;EAGF,IAAI,WAA4B,EAAE;AAClC,MAAI;AAKF,cAAW,kBAJa,MAAM,IAAI,OAAO,QAAQ,SAAS;IACxD,MAAM,EAAE,IAAI,WAAW;IACvB,OAAO,EAAE,WAAW,IAAI,WAAW;IACpC,CAAC,EAC4C,EAAE,CAAoB;WAC7D,QAAQ;AACf;;AAGF,MAAI,8BAA8B,SAAS,CACzC,OAAM,kBAAkB,OAAO,KAAK;EAGtC,MAAM,eAAe,oBAAoB,SAAS;EAClD,MAAM,WAAW;GACf;GACA,iBAAiB,uBAAuB,MAAM;GAC9C;GACD;EAED,MAAM,WAAW,mBAAmB;GAClC;GACA;GACA;GACA,WAAW,UAAU,UAAU,UAAU;GACzC,2BACE,OAAO,4BAA4B,UAAU,IAAI;GACpD,CAAC;AAEF,MAAI,CAAC,SAAS,IAAI;AAChB,aAAU,IAAI;IACZ,MAAM;IACN;IACA,QAAQ,SAAS;IAClB,CAAC;AACF;;AAGF,YAAU,IAAI;GAAE,MAAM;GAAqB;GAAW,CAAC;AACvD,iBAAe;GACb;GACA;GACA;GACA;GACA,iBAAiB,SAAS;GAC1B,WAAW,YAAY;IACrB,MAAM,sBAAsB,OAAO,IAAI,UAAU;AACjD,QACE,oBAAoB,kBACpB,OAAO,KAAK,GAAG,oBAAoB,iBACjC,OAAO,iBAET;IAGF,MAAM,SAAS,MAAM,mBAAmB;KACtC;KACA;KACA;KACA,OAAO;KACP;KACD,CAAC;AAEF,cAAU,IAAI;KACZ,MAAM,OAAO,WAAW,aAAa,aAAa;KAClD;KACA,QAAQ,OAAO;KAChB,CAAC;;GAEL,CAAC;;CAGJ,MAAM,sBAAsB,WAAmB,UAAuB;EACpE,MAAM,QAAQ,OAAO,IAAI,UAAU;AACnC,MACE,MAAM,SAAS,mBACf,iBAAiB,MAAM,WAAW,MAAM,EACxC;AACA,SAAM,kBAAkB,OAAO,KAAK;AACpC,aAAU,IAAI;IAAE,MAAM;IAAkB;IAAW,CAAC;SAC/C;AACL,SAAM,kBAAkB;AACxB,aAAU,IAAI;IAAE,MAAM;IAAmB;IAAW,CAAC;;AAEvD,4BAA0B,UAAU;;CAGtC,MAAM,yBAAyB,WAAmB,UAAuB;AACvE,MAAI,MAAM,SAAS,sBAAsB,CAAC,SAAS,MAAM,WAAW,CAClE;AAGF,MAAI,MAAM,WAAW,SAAS,OAAO,YAAY,QAAQ,KAAK,GAAG,EAAE;AACjE,aAAU,WAAW,WAAW,KAAK;AACrC,aAAU,IAAI;IAAE,MAAM;IAAoB;IAAW,CAAC;;;CAI1D,MAAM,4BACJ,WACA,UACY;AAEZ,MADa,mBAAmB,MAAM,KACzB,OACX,QAAO;EAGT,MAAM,QAAQ,OAAO,IAAI,UAAU;AACnC,SAAO,QACL,MAAM,sBACJ,OAAO,KAAK,GAAG,MAAM,qBAAqB,OAAO,iBACpD;;CAGH,MAAM,UAAU,OAAO,UAA2C;AAChE,MAAI,CAAC,OAAO,QACV;EAGF,MAAM,EAAE,UAAU;EAClB,MAAM,YAAY,iBAAiB,MAAM;AACzC,MAAI,CAAC,UACH;AAGF,UAAQ,MAAM,MAAd;GACE,KAAK;AACH,UAAM,WAAW,UAAU;AAC3B;GAEF,KAAK;AACH,WAAO,MAAM,UAAU;AACvB,cAAU,MAAM,UAAU;AAC1B,cAAU,IAAI;KAAE,MAAM;KAAmB;KAAW,CAAC;AACrD;GAEF,KAAK;AACH,uBAAmB,WAAW,MAAM;AACpC;GAEF,KAAK;AACH,0BAAsB,WAAW,MAAM;AACvC;GAEF,KAAK;AACH,QAAI,yBAAyB,WAAW,MAAM,CAC5C;AAEF,sBAAkB,UAAU;AAC5B;GAEF,KAAK;GACL,KAAK;AACH,sBAAkB,UAAU;AAC5B;GAEF,QACE;;;CAKN,MAAM,gBAAgB,OACpB,OACA,WACkB;AAClB,MAAI,CAAC,OAAO,QACV;EAGF,MAAM,OAAO,gBAAgB,OAAO,MAAM;AAC1C,YAAU,IAAI;GAAE,MAAM;GAAqB,WAAW,MAAM;GAAW,CAAC;AACxE,MAAI,SAAS,OAAO,aAAa;AAC/B,aAAU,WAAW,MAAM,WAAW,KAAK;AAC3C,aAAU,IAAI;IAAE,MAAM;IAAiB,WAAW,MAAM;IAAW,CAAC;AACpE,SAAM,IAAI,OAAO,IACd,UAAU,EACT,MAAM;IACJ,SAAS;IACT,SAAS;IACV,EACF,CAAC,CACD,MAAM,MAAM;AACf;;AAGF,MAAI,UAAU,UAAU,MAAM,UAAU,EAAE;AACxC,aAAU,WAAW,MAAM,WAAW,MAAM;AAC5C,aAAU,IAAI;IAAE,MAAM;IAAqB,WAAW,MAAM;IAAW,CAAC;AACxE,SAAM,IAAI,OAAO,IACd,UAAU,EACT,MAAM;IACJ,SAAS;IACT,SAAS;IACV,EACF,CAAC,CACD,MAAM,MAAM;;;CAInB,MAAM,uBAAuB,UAAuC;AAClE,MAAI,CAAC,OAAO,QACV;AAEF,oBAAkB,MAAM,UAAU;;CAGpC,MAAM,sBAAsB,UAAuC;AACjE,MAAI,CAAC,OAAO,QACV;AAEF,oBAAkB,MAAM,UAAU;;AAGpC,QAAO;EACL;EACA;EACA;EACA;EACD;;;;;ACxZH,MAAa,6BAA6C;CACxD,MAAM,kCAAkB,IAAI,KAAa;AAEzC,QAAO;EACL,YAAY,cAA+B,gBAAgB,IAAI,UAAU;EACzE,aAAa,WAAmB,UAAyB;AACvD,OAAI,OAAO;AACT,oBAAgB,IAAI,UAAU;AAC9B;;AAEF,mBAAgB,OAAO,UAAU;;EAEnC,QAAQ,cAA4B;AAClC,mBAAgB,OAAO,UAAU;;EAEpC;;;;;ACTH,MAAa,sBAA8B,UAAU;CAGnD,MAAM,eAAe,+BAA+B;EAClD,KAAK;EACL,QAJa,0BAA0B;EAKvC,WAJgB,sBAAsB;EAKvC,CAAC;AAEF,QAAO,QAAQ,QAAQ;EACrB,MAAM,EACJ,0BAA0B,2BAC3B;EACD,OAAO,aAAa;EACpB,gBAAgB,OAAO,SAAS,WAAW;AACzC,SAAM,aAAa,cAAc,SAAS,OAAO;;EAEnD,uBAAuB,OAAO,YAAY;AACxC,SAAM,aAAa,oBAAoB,QAAQ;;EAEjD,sBAAsB,OAAO,YAAY;AACvC,SAAM,aAAa,mBAAmB,QAAQ;;EAEjD,CAAC;;AAGJ,MAAa,4BACX,YACW;AACX,SAAQ,UAAU;EAGhB,MAAM,eAAe,+BAA+B;GAClD,KAAK;GACL,QAJa,yBAAyB,QAAQ;GAK9C,WAJgB,sBAAsB;GAKvC,CAAC;AAEF,SAAO,QAAQ,QAAQ;GACrB,MAAM,EACJ,0BAA0B,2BAC3B;GACD,OAAO,aAAa;GACpB,gBAAgB,OAAO,SAAS,WAAW;AACzC,UAAM,aAAa,cAAc,SAAS,OAAO;;GAEnD,uBAAuB,OAAO,YAAY;AACxC,UAAM,aAAa,oBAAoB,QAAQ;;GAEjD,sBAAsB,OAAO,YAAY;AACvC,UAAM,aAAa,mBAAmB,QAAQ;;GAEjD,CAAC"}
@@ -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
+ }