opencode-auto-loop 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) 2025 Tim Lang
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,128 @@
1
+ # opencode-auto-loop
2
+
3
+ Auto Loop plugin for [opencode](https://opencode.ai) — auto-continues until task completion.
4
+
5
+ ## Installation
6
+
7
+ Add to your `~/.config/opencode/opencode.json`:
8
+
9
+ ```json
10
+ {
11
+ "plugin": ["opencode-auto-loop"]
12
+ }
13
+ ```
14
+
15
+ Restart opencode. That's it!
16
+
17
+ On first run, the plugin will automatically install skills and commands to your `~/.config/opencode/` directory.
18
+
19
+ ## Usage
20
+
21
+ ### Start a loop
22
+
23
+ ```
24
+ /auto-loop "Build a REST API with authentication"
25
+ ```
26
+
27
+ The AI will work on your task and automatically continue until completion.
28
+
29
+ ### Cancel a loop
30
+
31
+ ```
32
+ /cancel-auto-loop
33
+ ```
34
+
35
+ ### Get help
36
+
37
+ ```
38
+ /auto-loop-help
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ 1. `/auto-loop` creates a state file at `.opencode/auto-loop.local.md`
44
+ 2. When the AI goes idle, the plugin checks if `<promise>DONE</promise>` was output
45
+ 3. If not found, it extracts progress (## Completed / ## Next Steps) and injects a continuation prompt
46
+ 4. Loop continues until DONE is found or max iterations (100) reached
47
+ 5. State file is deleted when complete
48
+ 6. Loop context survives session compaction
49
+
50
+ ### Progress Tracking
51
+
52
+ The plugin extracts `## Completed` and `## Next Steps` sections from each iteration and persists them in the state file. On continuation, these are included in the prompt so the AI knows exactly where to pick up.
53
+
54
+ ### Completion Promise
55
+
56
+ When the AI finishes a task, it outputs:
57
+
58
+ ```
59
+ <promise>DONE</promise>
60
+ ```
61
+
62
+ The AI should ONLY output this when the task is COMPLETELY and VERIFIABLY finished.
63
+
64
+ ## State File
65
+
66
+ The loop state is stored in your project directory:
67
+
68
+ ```
69
+ .opencode/auto-loop.local.md
70
+ ```
71
+
72
+ Format (markdown with YAML frontmatter):
73
+
74
+ ```markdown
75
+ ---
76
+ active: true
77
+ iteration: 3
78
+ maxIterations: 100
79
+ sessionId: ses_abc123
80
+ ---
81
+
82
+ Your original task prompt
83
+
84
+ ## Completed
85
+ - [x] Set up project structure
86
+ - [x] Created database schema
87
+
88
+ ## Next Steps
89
+ - [ ] Add JWT authentication middleware
90
+ - [ ] Create registration endpoint
91
+ ```
92
+
93
+ Add `.opencode/auto-loop.local.md` to your `.gitignore`.
94
+
95
+ ## Features
96
+
97
+ - **Plug-and-play**: Just add to config and restart
98
+ - **Auto-setup**: Skills and commands are automatically installed on first run
99
+ - **Progress tracking**: Extracts and persists TODOs across iterations
100
+ - **Compaction-safe**: Loop context survives session compaction
101
+ - **Project-relative**: State file in `.opencode/`, not global
102
+ - **Completion detection**: Scans session messages for DONE promise (ignores code fences)
103
+ - **Toast notifications**: Visual feedback on loop start, iteration, completion
104
+ - **Error handling**: Pauses on session errors, cleans up on session deletion
105
+ - **Debounced**: Prevents duplicate continuations from rapid idle events
106
+ - **Commands**: `/auto-loop`, `/cancel-auto-loop`, and `/auto-loop-help`
107
+
108
+ ## Architecture
109
+
110
+ ```
111
+ opencode-auto-loop/
112
+ ├── src/
113
+ │ └── index.ts # Main plugin with event hooks and tools
114
+ ├── skills/
115
+ │ ├── auto-loop/ # Progressive context for starting loops
116
+ │ ├── cancel-auto-loop/ # Context for cancellation
117
+ │ └── help/ # Plugin documentation
118
+ ├── commands/
119
+ │ ├── auto-loop.md # Slash command for starting
120
+ │ ├── cancel-auto-loop.md # Slash command for cancelling
121
+ │ └── help.md # Slash command for help
122
+ ├── tsconfig.json
123
+ └── package.json
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,36 @@
1
+ ---
2
+ description: Show Auto Loop plugin help and available commands
3
+ ---
4
+
5
+ # Auto Loop Help
6
+
7
+ ## Available Commands
8
+
9
+ - `/auto-loop <task>` - Start an auto-continuation loop for the given task
10
+ - `/cancel-auto-loop` - Stop an active Auto Loop
11
+ - `/auto-loop-help` - Show this help
12
+
13
+ ## Quick Start
14
+
15
+ ```
16
+ /auto-loop Build a REST API with user authentication
17
+ ```
18
+
19
+ The AI will work on your task and automatically continue until it outputs `<promise>DONE</promise>` to signal completion.
20
+
21
+ ## How It Works
22
+
23
+ 1. Creates state file at `.opencode/auto-loop.local.md`
24
+ 2. Works on task until idle
25
+ 3. If no `<promise>DONE</promise>` found, auto-continues
26
+ 4. Repeats until complete or max iterations (100) reached
27
+ 5. Survives context compaction — loop state is injected into the summary
28
+
29
+ ## Cancellation
30
+
31
+ To stop early:
32
+ ```
33
+ /cancel-auto-loop
34
+ ```
35
+
36
+ For more details, the AI can use the `auto-loop-help` tool.
@@ -0,0 +1,55 @@
1
+ ---
2
+ description: Start Auto Loop - auto-continues until task completion
3
+ ---
4
+
5
+ # Auto Loop
6
+
7
+ Start an iterative development loop that automatically continues until the task is complete.
8
+
9
+ ## Setup
10
+
11
+ Create the state file in the project directory:
12
+
13
+ ```bash
14
+ mkdir -p .opencode && cat > .opencode/auto-loop.local.md << 'EOF'
15
+ ---
16
+ active: true
17
+ iteration: 0
18
+ maxIterations: 100
19
+ ---
20
+
21
+ $ARGUMENTS
22
+ EOF
23
+ ```
24
+
25
+ ## Task
26
+
27
+ Now begin working on the task: **$ARGUMENTS**
28
+
29
+ ## Progress Tracking
30
+
31
+ Before going idle, you MUST output structured progress so the plugin knows where you left off:
32
+
33
+ ```markdown
34
+ ## Completed
35
+ - [x] What you finished this iteration
36
+
37
+ ## Next Steps
38
+ - [ ] What needs to be done next (in priority order)
39
+ ```
40
+
41
+ The plugin extracts these into the state file for the next iteration's continuation prompt.
42
+
43
+ ## Completion
44
+
45
+ When the task is FULLY completed, signal completion by outputting:
46
+
47
+ ```
48
+ <promise>DONE</promise>
49
+ ```
50
+
51
+ **IMPORTANT:** ONLY output this when the task is COMPLETELY and VERIFIABLY finished. Do NOT output false promises to escape the loop.
52
+
53
+ ## Cancellation
54
+
55
+ Use `/cancel-auto-loop` to stop early.
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: Cancel active Auto Loop
3
+ ---
4
+
5
+ # Cancel Loop
6
+
7
+ Cancel the active Auto Loop.
8
+
9
+ ## Steps
10
+
11
+ 1. Check if a loop is active and get the iteration count:
12
+
13
+ ```bash
14
+ if [ -f .opencode/auto-loop.local.md ]; then
15
+ grep '^iteration:' .opencode/auto-loop.local.md
16
+ rm -f .opencode/auto-loop.local.md
17
+ echo "Auto Loop cancelled."
18
+ else
19
+ echo "No active Auto Loop to cancel."
20
+ fi
21
+ ```
22
+
23
+ 2. Report the result to the user.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "opencode-auto-loop",
3
+ "version": "0.1.0",
4
+ "description": "Auto-continue for OpenCode",
5
+ "main": "src/index.ts",
6
+ "type": "module",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "auto-continue",
11
+ "auto-loop",
12
+ "iteration-loop"
13
+ ],
14
+ "author": "Tim Lang",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/timmyjl12/opencode-auto-loop"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/timmyjl12/opencode-auto-loop/issues"
22
+ },
23
+ "homepage": "https://github.com/timmyjl12/opencode-auto-loop#readme",
24
+ "files": [
25
+ "src/",
26
+ "commands/",
27
+ "skills/"
28
+ ],
29
+ "dependencies": {
30
+ "@opencode-ai/plugin": "^0.15.31"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^25.5.0"
37
+ }
38
+ }
@@ -0,0 +1,116 @@
1
+ ---
2
+ name: auto-loop
3
+ description: Start Auto Loop - auto-continues until task completion
4
+ ---
5
+
6
+ # Auto Loop
7
+
8
+ Start an iterative development loop that automatically continues until the task is complete.
9
+
10
+ ## How It Works
11
+
12
+ The Auto Loop creates a continuous feedback cycle for completing complex tasks:
13
+
14
+ 1. You work on the task until you go idle
15
+ 2. The plugin detects the idle state and checks for completion
16
+ 3. If not complete, it extracts your progress and prompts you to continue
17
+ 4. This repeats until you output the completion promise or max iterations reached
18
+
19
+ Your previous work remains accessible through files, git history, and the state file's progress sections.
20
+
21
+ ## Starting the Loop
22
+
23
+ When you invoke this skill, create the state file in the project directory:
24
+
25
+ ```bash
26
+ mkdir -p .opencode && cat > .opencode/auto-loop.local.md << 'EOF'
27
+ ---
28
+ active: true
29
+ iteration: 0
30
+ maxIterations: 100
31
+ ---
32
+
33
+ [The user's task prompt goes here]
34
+ EOF
35
+ ```
36
+
37
+ Then inform the user and begin working on the task.
38
+
39
+ ## Progress Tracking - CRITICAL
40
+
41
+ **Before going idle at the end of each work session, you MUST output structured progress sections.** The plugin parses these to persist your TODOs across iterations so you know exactly where to pick up.
42
+
43
+ Use this format in your final message of each iteration:
44
+
45
+ ```markdown
46
+ ## Completed
47
+ - [x] Set up project structure
48
+ - [x] Created database schema
49
+ - [x] Implemented user model
50
+
51
+ ## Next Steps
52
+ - [ ] Add JWT authentication middleware
53
+ - [ ] Create registration endpoint
54
+ - [ ] Write integration tests
55
+ ```
56
+
57
+ **Rules:**
58
+ - Always use checkbox format (`- [x]` for done, `- [ ]` for remaining)
59
+ - Be specific — each item should be a concrete, actionable step
60
+ - Only list truly completed items under ## Completed
61
+ - Order ## Next Steps by priority — the continuation will tell you to start from the top
62
+ - The plugin extracts these sections and writes them into `auto-loop.local.md` for the next iteration
63
+
64
+ ## Completion Promise - CRITICAL RULES
65
+
66
+ When you have FULLY completed the task, signal completion by outputting:
67
+
68
+ ```
69
+ <promise>DONE</promise>
70
+ ```
71
+
72
+ **IMPORTANT CONSTRAINTS:**
73
+
74
+ - ONLY output `<promise>DONE</promise>` when the task is COMPLETELY and VERIFIABLY finished
75
+ - The statement MUST be completely and unequivocally TRUE
76
+ - Do NOT output false promises to escape the loop, even if you think you're stuck
77
+ - Do NOT lie even if you think you should exit for other reasons
78
+ - If you're blocked, explain the blocker and request help instead of falsely completing
79
+
80
+ The loop can only be stopped by:
81
+ 1. Truthful completion promise
82
+ 2. Max iterations reached
83
+ 3. User running `/cancel-auto-loop`
84
+
85
+ ## Checking Status
86
+
87
+ Check current iteration and progress:
88
+ ```bash
89
+ cat .opencode/auto-loop.local.md
90
+ ```
91
+
92
+ ## State File Format
93
+
94
+ The state file at `.opencode/auto-loop.local.md` uses YAML frontmatter with progress sections:
95
+
96
+ ```markdown
97
+ ---
98
+ active: true
99
+ iteration: 3
100
+ maxIterations: 100
101
+ sessionId: ses_abc123
102
+ ---
103
+
104
+ Build a REST API with authentication
105
+
106
+ ## Completed
107
+ - [x] Set up project structure
108
+ - [x] Created database schema
109
+
110
+ ## Next Steps
111
+ - [ ] Add JWT authentication middleware
112
+ - [ ] Create registration endpoint
113
+ - [ ] Write integration tests
114
+ ```
115
+
116
+ Add `.opencode/auto-loop.local.md` to your `.gitignore`.
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: auto-loop-help
3
+ description: Explain Auto Loop plugin and available commands
4
+ ---
5
+
6
+ # Auto Loop Help
7
+
8
+ The Auto Loop plugin provides auto-continuation for complex tasks in opencode.
9
+
10
+ ## Available Commands
11
+
12
+ ### `/auto-loop <task>`
13
+ Start an iterative development loop that automatically continues until the task is complete.
14
+
15
+ Example:
16
+ ```
17
+ /auto-loop Build a REST API with authentication
18
+ ```
19
+
20
+ The AI will work on your task and automatically continue until completion.
21
+
22
+ ### `/cancel-auto-loop`
23
+ Cancel an active Auto Loop before it completes.
24
+
25
+ Example:
26
+ ```
27
+ /cancel-auto-loop
28
+ ```
29
+
30
+ ### `/auto-loop-help`
31
+ Show plugin help and available commands.
32
+
33
+ ## How It Works
34
+
35
+ 1. **Start**: `/auto-loop` creates a state file at `.opencode/auto-loop.local.md`
36
+ 2. **Loop**: When the AI goes idle, the plugin checks if `<promise>DONE</promise>` was output
37
+ 3. **Continue**: If not found, it injects "Continue from where you left off"
38
+ 4. **Stop**: Loop continues until DONE is found or max iterations (100) reached
39
+ 5. **Cleanup**: State file is deleted when complete
40
+ 6. **Compaction**: Loop context survives session compaction — task and iteration info is preserved
41
+
42
+ ## Completion Signal
43
+
44
+ When the task is fully complete, the AI outputs:
45
+
46
+ ```
47
+ <promise>DONE</promise>
48
+ ```
49
+
50
+ This signals the loop to stop. The AI should ONLY output this when the task is truly complete.
51
+
52
+ ## State File
53
+
54
+ Located at `.opencode/auto-loop.local.md` (add to `.gitignore`):
55
+
56
+ ```markdown
57
+ ---
58
+ active: true
59
+ iteration: 3
60
+ maxIterations: 100
61
+ sessionId: ses_abc123
62
+ ---
63
+
64
+ Your original task prompt
65
+
66
+ ## Completed
67
+ - [x] Set up project structure
68
+
69
+ ## Next Steps
70
+ - [ ] Add authentication
71
+ ```
72
+
73
+ ## Credits
74
+
75
+ - Inspired by [Anthropic's auto-continue plugin](https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum) for Claude Code
76
+ - Based on [opencode-auto-loop](https://github.com/timmyjl12/opencode-auto-loop)
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: cancel-auto-loop
3
+ description: Cancel active Auto Loop
4
+ ---
5
+
6
+ # Cancel Loop
7
+
8
+ Stop an active Auto Loop before completion.
9
+
10
+ ## How to Use
11
+
12
+ When you invoke this skill:
13
+
14
+ 1. First, check if a loop is active:
15
+
16
+ ```bash
17
+ test -f .opencode/auto-loop.local.md && echo "Loop is active" || echo "No active loop"
18
+ ```
19
+
20
+ 2. If active, read the current iteration count:
21
+
22
+ ```bash
23
+ grep '^iteration:' .opencode/auto-loop.local.md
24
+ ```
25
+
26
+ 3. Delete the state file to stop the loop:
27
+
28
+ ```bash
29
+ rm -f .opencode/auto-loop.local.md
30
+ ```
31
+
32
+ 4. Inform the user of the cancellation and which iteration was reached.
33
+
34
+ ## When to Use
35
+
36
+ Use this command when:
37
+ - The task requirements have changed
38
+ - You want to restart with different parameters
39
+ - The loop appears stuck and you want manual control
40
+ - You need to work on something else
41
+
42
+ Note: Prefer completing tasks properly with `<promise>DONE</promise>` when possible.
package/src/index.ts ADDED
@@ -0,0 +1,596 @@
1
+ import { type Plugin, type PluginInput, tool } from "@opencode-ai/plugin";
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ mkdirSync,
7
+ unlinkSync,
8
+ cpSync,
9
+ } from "fs";
10
+ import { dirname, join } from "path";
11
+ import { fileURLToPath } from "url";
12
+ import { homedir } from "os";
13
+
14
+ // Types
15
+ interface LoopState {
16
+ active: boolean;
17
+ iteration: number;
18
+ maxIterations: number;
19
+ sessionId?: string;
20
+ prompt?: string;
21
+ completed?: string;
22
+ nextSteps?: string;
23
+ }
24
+
25
+ type LogLevel = "debug" | "info" | "warn" | "error";
26
+ type LogFn = (level: LogLevel, message: string) => void;
27
+ type OpencodeClient = PluginInput["client"];
28
+
29
+ // Constants
30
+ const SERVICE_NAME = "auto-loop";
31
+ const STATE_FILENAME = "auto-loop.local.md";
32
+ const OPENCODE_CONFIG_DIR = join(homedir(), ".config/opencode");
33
+ const COMPLETION_TAG = /<promise>\s*DONE\s*<\/promise>/is;
34
+ const DEBOUNCE_MS = 2000;
35
+
36
+ // Get plugin root directory (ESM only — package is "type": "module")
37
+ function getPluginRoot(): string {
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ return dirname(dirname(__filename)); // Go up from src/ to plugin root
40
+ }
41
+
42
+ // Content-aware copy: always update if content differs
43
+ function copyIfChanged(src: string, dest: string): void {
44
+ if (!existsSync(src)) return;
45
+ if (existsSync(dest)) {
46
+ const srcContent = readFileSync(src, "utf-8");
47
+ const destContent = readFileSync(dest, "utf-8");
48
+ if (srcContent === destContent) return;
49
+ }
50
+ const destDir = dirname(dest);
51
+ mkdirSync(destDir, { recursive: true });
52
+ cpSync(src, dest, { recursive: true });
53
+ }
54
+
55
+ // Auto-copy skills and commands to opencode config, updating if content changed
56
+ function setupSkillsAndCommands(log: LogFn): void {
57
+ const pluginRoot = getPluginRoot();
58
+ const skillsDir = join(OPENCODE_CONFIG_DIR, "skill");
59
+ const commandsDir = join(OPENCODE_CONFIG_DIR, "command");
60
+
61
+ // Copy skills
62
+ const pluginSkillsDir = join(pluginRoot, "skills");
63
+ if (existsSync(pluginSkillsDir)) {
64
+ const skills = ["auto-loop", "cancel-auto-loop", "auto-loop-help"];
65
+ for (const skill of skills) {
66
+ const srcFile = join(pluginSkillsDir, skill, "SKILL.md");
67
+ const destFile = join(skillsDir, skill, "SKILL.md");
68
+ try {
69
+ copyIfChanged(srcFile, destFile);
70
+ } catch (err) {
71
+ log("warn", `Failed to copy skill '${skill}': ${err}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ // Copy commands
77
+ const pluginCommandsDir = join(pluginRoot, "commands");
78
+ if (existsSync(pluginCommandsDir)) {
79
+ const commands = ["auto-loop.md", "cancel-auto-loop.md", "auto-loop-help.md"];
80
+ for (const cmd of commands) {
81
+ try {
82
+ copyIfChanged(join(pluginCommandsDir, cmd), join(commandsDir, cmd));
83
+ } catch (err) {
84
+ log("warn", `Failed to copy command '${cmd}': ${err}`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ // Get state file path (project-relative)
91
+ function getStateFile(directory: string): string {
92
+ return join(directory, ".opencode", STATE_FILENAME);
93
+ }
94
+
95
+ // Parse markdown frontmatter state
96
+ function parseState(content: string): LoopState {
97
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
98
+ if (!match) return { active: false, iteration: 0, maxIterations: 100 };
99
+
100
+ const frontmatter = match[1];
101
+ const state: LoopState = {
102
+ active: false,
103
+ iteration: 0,
104
+ maxIterations: 100,
105
+ };
106
+
107
+ for (const line of frontmatter.split("\n")) {
108
+ const [key, ...valueParts] = line.split(":");
109
+ const value = valueParts.join(":").trim();
110
+ if (key === "active") state.active = value === "true";
111
+ if (key === "iteration") state.iteration = parseInt(value) || 0;
112
+ if (key === "maxIterations") state.maxIterations = parseInt(value) || 100;
113
+ if (key === "sessionId") state.sessionId = value || undefined;
114
+ }
115
+
116
+ // Get prompt and progress sections from body (after frontmatter)
117
+ const body = content.slice(match[0].length).trim();
118
+ if (body) {
119
+ // Split body at ## Completed / ## Next Steps section boundaries
120
+ const parts = body.split(/^(?=## (?:Completed|Next Steps))/m);
121
+
122
+ // First part is always the original prompt
123
+ state.prompt = parts[0].trim();
124
+
125
+ // Remaining parts are the progress sections
126
+ for (let i = 1; i < parts.length; i++) {
127
+ const section = parts[i];
128
+ if (section.startsWith("## Completed")) {
129
+ state.completed = section.replace(/^## Completed\n?/, "").trim();
130
+ } else if (section.startsWith("## Next Steps")) {
131
+ state.nextSteps = section.replace(/^## Next Steps\n?/, "").trim();
132
+ }
133
+ }
134
+ }
135
+
136
+ return state;
137
+ }
138
+
139
+ // Serialize state to markdown frontmatter with progress sections
140
+ function serializeState(state: LoopState): string {
141
+ const lines = [
142
+ "---",
143
+ `active: ${state.active}`,
144
+ `iteration: ${state.iteration}`,
145
+ `maxIterations: ${state.maxIterations}`,
146
+ ];
147
+ if (state.sessionId) lines.push(`sessionId: ${state.sessionId}`);
148
+ lines.push("---");
149
+ if (state.prompt) lines.push("", state.prompt);
150
+ if (state.completed) lines.push("", "## Completed", state.completed);
151
+ if (state.nextSteps) lines.push("", "## Next Steps", state.nextSteps);
152
+ return lines.join("\n");
153
+ }
154
+
155
+ // Read state from project directory
156
+ function readState(directory: string): LoopState {
157
+ const stateFile = getStateFile(directory);
158
+ if (existsSync(stateFile)) {
159
+ return parseState(readFileSync(stateFile, "utf-8"));
160
+ }
161
+ return { active: false, iteration: 0, maxIterations: 100 };
162
+ }
163
+
164
+ // Write state to project directory
165
+ function writeState(directory: string, state: LoopState, log: LogFn): void {
166
+ try {
167
+ const stateFile = getStateFile(directory);
168
+ mkdirSync(dirname(stateFile), { recursive: true });
169
+ writeFileSync(stateFile, serializeState(state));
170
+ } catch (err) {
171
+ log("error", `Failed to write state: ${err}`);
172
+ }
173
+ }
174
+
175
+ // Clear state
176
+ function clearState(directory: string, log: LogFn): void {
177
+ try {
178
+ const stateFile = getStateFile(directory);
179
+ if (existsSync(stateFile)) unlinkSync(stateFile);
180
+ } catch (err) {
181
+ log("warn", `Failed to clear state: ${err}`);
182
+ }
183
+ }
184
+
185
+ // Strip markdown code fences before checking for completion tag
186
+ function stripCodeFences(text: string): string {
187
+ return text.replace(/```[\s\S]*?```/g, "");
188
+ }
189
+
190
+ // Extract text from the last assistant message in a session
191
+ async function getLastAssistantText(
192
+ client: OpencodeClient,
193
+ sessionId: string,
194
+ directory: string,
195
+ log: LogFn
196
+ ): Promise<string | null> {
197
+ try {
198
+ const response = await client.session.messages({
199
+ path: { id: sessionId },
200
+ query: { directory },
201
+ });
202
+
203
+ const messages = response.data ?? [];
204
+
205
+ const assistantMessages = messages.filter(
206
+ (msg) => msg.info?.role === "assistant"
207
+ );
208
+
209
+ if (assistantMessages.length === 0) return null;
210
+
211
+ const lastAssistant = assistantMessages[assistantMessages.length - 1];
212
+ const parts = lastAssistant.parts || [];
213
+
214
+ return parts
215
+ .filter((p) => p.type === "text")
216
+ .map((p) => ("text" in p ? p.text : "") ?? "")
217
+ .join("\n");
218
+ } catch (err) {
219
+ log("warn", `Failed to fetch session messages: ${err}`);
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // Check completion by looking for <promise>DONE</promise> in last assistant text
225
+ function checkCompletion(text: string): boolean {
226
+ return COMPLETION_TAG.test(stripCodeFences(text));
227
+ }
228
+
229
+ // Extract next steps / TODOs from assistant message text
230
+ // Looks for common patterns: ## Next Steps, ## TODO, checkbox lists, numbered lists after keywords
231
+ function extractNextSteps(text: string): string | undefined {
232
+ // Strategy 1: Look for an explicit ## Next Steps / ## TODO / ## Remaining section
233
+ const sectionMatch = text.match(
234
+ /^##\s*(?:Next Steps|TODO|Remaining|What's Left|Still To Do|Outstanding)[^\n]*\n([\s\S]*?)(?=\n## |\n\n---|$)/im
235
+ );
236
+ if (sectionMatch) {
237
+ const content = sectionMatch[1].trim();
238
+ if (content) return content;
239
+ }
240
+
241
+ // Strategy 2: Collect all unchecked checkbox items (- [ ] ...)
242
+ const unchecked = text
243
+ .split("\n")
244
+ .filter((line) => /^\s*-\s*\[ \]/.test(line))
245
+ .map((line) => line.trim());
246
+ if (unchecked.length > 0) {
247
+ return unchecked.join("\n");
248
+ }
249
+
250
+ // Strategy 3: Look for a numbered list after "next" or "todo" or "remaining" keywords
251
+ const numberedMatch = text.match(
252
+ /(?:next|todo|remaining|still need to|still to do)[^\n]*\n((?:\s*\d+\.\s+[^\n]+\n?)+)/i
253
+ );
254
+ if (numberedMatch) {
255
+ return numberedMatch[1].trim();
256
+ }
257
+
258
+ return undefined;
259
+ }
260
+
261
+ // Extract completed items from assistant message text
262
+ function extractCompleted(text: string): string | undefined {
263
+ // Strategy 1: Look for an explicit ## Completed / ## Done / ## Progress section
264
+ const sectionMatch = text.match(
265
+ /^##\s*(?:Completed|Done|Progress|Accomplished|Finished)[^\n]*\n([\s\S]*?)(?=\n## |\n\n---|$)/im
266
+ );
267
+ if (sectionMatch) {
268
+ const content = sectionMatch[1].trim();
269
+ if (content) return content;
270
+ }
271
+
272
+ // Strategy 2: Collect all checked checkbox items (- [x] ...)
273
+ const checked = text
274
+ .split("\n")
275
+ .filter((line) => /^\s*-\s*\[x\]/i.test(line))
276
+ .map((line) => line.trim());
277
+ if (checked.length > 0) {
278
+ return checked.join("\n");
279
+ }
280
+
281
+ return undefined;
282
+ }
283
+
284
+ // Merge completed items: deduplicate by normalizing checkbox text
285
+ function mergeCompleted(
286
+ existing: string | undefined,
287
+ incoming: string | undefined
288
+ ): string | undefined {
289
+ if (!existing && !incoming) return undefined;
290
+ if (!existing) return incoming;
291
+ if (!incoming) return existing;
292
+
293
+ const existingLines = existing.split("\n").map((l) => l.trim()).filter(Boolean);
294
+ const incomingLines = incoming.split("\n").map((l) => l.trim()).filter(Boolean);
295
+
296
+ // Normalize for dedup: strip checkbox prefix and lowercase
297
+ const normalize = (line: string) =>
298
+ line.replace(/^-\s*\[x\]\s*/i, "").trim().toLowerCase();
299
+ const existingSet = new Set(existingLines.map(normalize));
300
+
301
+ for (const line of incomingLines) {
302
+ if (!existingSet.has(normalize(line))) {
303
+ existingLines.push(line);
304
+ existingSet.add(normalize(line));
305
+ }
306
+ }
307
+
308
+ return existingLines.join("\n");
309
+ }
310
+
311
+ // Build a progress section for the continuation prompt
312
+ function buildProgressSection(state: LoopState): string {
313
+ const sections: string[] = [];
314
+
315
+ if (state.completed) {
316
+ sections.push(`\n## Completed So Far\n${state.completed}`);
317
+ }
318
+ if (state.nextSteps) {
319
+ sections.push(`\n## Next Steps (pick up here)\n${state.nextSteps}`);
320
+ }
321
+ if (sections.length === 0) {
322
+ sections.push(
323
+ "\nNo structured progress recorded yet. Review your work so far and continue."
324
+ );
325
+ }
326
+
327
+ return sections.join("\n");
328
+ }
329
+
330
+ // Build the loop context reminder for post-compaction injection
331
+ function buildLoopContextReminder(state: LoopState): string {
332
+ const progress = buildProgressSection(state);
333
+ return `[AUTO LOOP ACTIVE — Iteration ${state.iteration}/${state.maxIterations}]
334
+
335
+ Original task: ${state.prompt || "(no task specified)"}
336
+ ${progress}
337
+ When the task is FULLY complete, you MUST output: <promise>DONE</promise>
338
+ Before going idle, list your progress using ## Completed and ## Next Steps sections.
339
+ Do NOT output false completion promises. If blocked, explain the blocker.`;
340
+ }
341
+
342
+ // Main plugin
343
+ export const AutoLoopPlugin: Plugin = async (ctx) => {
344
+ const directory = ctx.directory || process.cwd();
345
+ const client = ctx.client;
346
+
347
+ // Structured logger using the SDK's app.log API
348
+ const log: LogFn = (level, message) => {
349
+ try {
350
+ client.app.log({
351
+ body: { service: SERVICE_NAME, level, message },
352
+ });
353
+ } catch {
354
+ // Last resort: if logging itself fails, silently ignore
355
+ }
356
+ };
357
+
358
+ // Toast helper using the SDK's tui.showToast API
359
+ const toast = (message: string, variant: "info" | "success" | "warning" | "error" = "info") => {
360
+ try {
361
+ client.tui.showToast({
362
+ body: { message, variant },
363
+ });
364
+ } catch {
365
+ // Non-critical — ignore
366
+ }
367
+ };
368
+
369
+ // Auto-setup skills and commands
370
+ setupSkillsAndCommands(log);
371
+
372
+ // Debounce tracking for idle events
373
+ let lastContinuation = 0;
374
+
375
+ return {
376
+ tool: {
377
+ "auto-loop": tool({
378
+ description:
379
+ "Start Auto Loop — auto-continues until task completion. Use: /auto-loop <task description>",
380
+ args: {
381
+ task: tool.schema
382
+ .string()
383
+ .describe("The task to work on until completion"),
384
+ maxIterations: tool.schema
385
+ .number()
386
+ .optional()
387
+ .describe("Maximum iterations (default: 100)"),
388
+ },
389
+ async execute({ task, maxIterations = 100 }, context) {
390
+ const state: LoopState = {
391
+ active: true,
392
+ iteration: 0,
393
+ maxIterations,
394
+ sessionId: context.sessionID,
395
+ prompt: task,
396
+ };
397
+ writeState(directory, state, log);
398
+
399
+ log("info", `Loop started for session ${context.sessionID}`);
400
+ toast(`Auto Loop started (max ${maxIterations} iterations)`, "success");
401
+
402
+ return `Auto Loop started (max ${maxIterations} iterations).
403
+
404
+ Task: ${task}
405
+
406
+ I will auto-continue until the task is complete. Before going idle each iteration, I will output structured progress:
407
+
408
+ \`\`\`
409
+ ## Completed
410
+ - [x] What I finished
411
+
412
+ ## Next Steps
413
+ - [ ] What remains (in priority order)
414
+ \`\`\`
415
+
416
+ When fully done, I will output \`<promise>DONE</promise>\` to signal completion.
417
+
418
+ Use /cancel-auto-loop to stop early.`;
419
+ },
420
+ }),
421
+
422
+ "cancel-auto-loop": tool({
423
+ description: "Cancel active Auto Loop",
424
+ args: {},
425
+ async execute() {
426
+ const state = readState(directory);
427
+ if (!state.active) {
428
+ return "No active Auto Loop to cancel.";
429
+ }
430
+ const iterations = state.iteration;
431
+ clearState(directory, log);
432
+
433
+ log("info", `Loop cancelled after ${iterations} iteration(s)`);
434
+ toast(`Auto Loop cancelled after ${iterations} iteration(s)`, "warning");
435
+
436
+ return `Auto Loop cancelled after ${iterations} iteration(s).`;
437
+ },
438
+ }),
439
+
440
+ "auto-loop-help": tool({
441
+ description: "Show Auto Loop plugin help",
442
+ args: {},
443
+ async execute() {
444
+ return `# Auto Loop Help
445
+
446
+ ## Available Commands
447
+
448
+ - \`/auto-loop <task>\` - Start an auto-continuation loop
449
+ - \`/cancel-auto-loop\` - Stop an active loop
450
+ - \`/auto-loop-help\` - Show this help
451
+
452
+ ## How It Works
453
+
454
+ 1. Start with: /auto-loop "Build a REST API"
455
+ 2. AI works on the task until idle
456
+ 3. Plugin auto-continues if not complete
457
+ 4. Loop stops when AI outputs: <promise>DONE</promise>
458
+
459
+ ## State File
460
+
461
+ Located at: .opencode/auto-loop.local.md`;
462
+ },
463
+ }),
464
+ },
465
+
466
+ // Event hooks for auto-continuation, compaction recovery, and lifecycle
467
+ event: async ({ event }) => {
468
+ // --- session.idle: core auto-continuation logic ---
469
+ if (event.type === "session.idle") {
470
+ const now = Date.now();
471
+ if (now - lastContinuation < DEBOUNCE_MS) return;
472
+
473
+ const sessionId = event.properties.sessionID;
474
+ const state = readState(directory);
475
+
476
+ if (!state.active) return;
477
+ if (!sessionId) return;
478
+ if (state.sessionId && state.sessionId !== sessionId) return;
479
+
480
+ // Fetch last assistant message (used for completion check + progress extraction)
481
+ const lastText = await getLastAssistantText(client, sessionId, directory, log);
482
+
483
+ if (lastText && checkCompletion(lastText)) {
484
+ clearState(directory, log);
485
+ log("info", `Loop completed at iteration ${state.iteration}`);
486
+ toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
487
+ return;
488
+ }
489
+
490
+ if (state.iteration >= state.maxIterations) {
491
+ clearState(directory, log);
492
+ log("warn", `Loop hit max iterations (${state.maxIterations})`);
493
+ toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
494
+ return;
495
+ }
496
+
497
+ // Extract progress from last message and merge with existing state
498
+ const newNextSteps = lastText ? extractNextSteps(lastText) : undefined;
499
+ const newCompleted = lastText ? extractCompleted(lastText) : undefined;
500
+
501
+ const newState: LoopState = {
502
+ ...state,
503
+ iteration: state.iteration + 1,
504
+ sessionId,
505
+ // Update next steps if we found new ones, otherwise keep previous
506
+ nextSteps: newNextSteps || state.nextSteps,
507
+ // Merge completed: append new completed items to existing
508
+ completed: mergeCompleted(state.completed, newCompleted),
509
+ };
510
+ writeState(directory, newState, log);
511
+ lastContinuation = Date.now();
512
+
513
+ // Build continuation prompt with progress context
514
+ const progressSection = buildProgressSection(newState);
515
+
516
+ const continuationPrompt = `[AUTO LOOP — ITERATION ${newState.iteration}/${newState.maxIterations}]
517
+
518
+ Continue working on the task. Do NOT repeat work that is already done.
519
+ ${progressSection}
520
+ IMPORTANT:
521
+ - Pick up from the next incomplete step below
522
+ - When FULLY complete, output: <promise>DONE</promise>
523
+ - Before going idle, list your progress using ## Completed and ## Next Steps sections
524
+ - Do not stop until the task is truly done
525
+
526
+ Original task:
527
+ ${state.prompt || "(no task specified)"}`;
528
+
529
+ try {
530
+ await client.session.prompt({
531
+ path: { id: sessionId },
532
+ body: {
533
+ parts: [{ type: "text", text: continuationPrompt }],
534
+ },
535
+ });
536
+ log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
537
+ toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
538
+ } catch (err) {
539
+ log("error", `Failed to send continuation prompt: ${err}`);
540
+ }
541
+ }
542
+
543
+ // --- session.compacted: re-inject loop context after compaction ---
544
+ if (event.type === "session.compacted") {
545
+ const sessionId = event.properties.sessionID;
546
+ const state = readState(directory);
547
+
548
+ if (!state.active) return;
549
+ if (state.sessionId && state.sessionId !== sessionId) return;
550
+
551
+ // After compaction, the AI loses loop context — send a reminder
552
+ try {
553
+ await client.session.prompt({
554
+ path: { id: sessionId },
555
+ body: {
556
+ parts: [{ type: "text", text: buildLoopContextReminder(state) }],
557
+ },
558
+ });
559
+ log("info", `Re-injected loop context after compaction for session ${sessionId}`);
560
+ } catch (err) {
561
+ log("warn", `Failed to re-inject loop context after compaction: ${err}`);
562
+ }
563
+ }
564
+
565
+ // --- session.error: pause the loop on error ---
566
+ if (event.type === "session.error") {
567
+ const sessionId = event.properties.sessionID;
568
+ const state = readState(directory);
569
+
570
+ if (
571
+ state.active &&
572
+ (!state.sessionId || state.sessionId === sessionId)
573
+ ) {
574
+ log("warn", `Session error detected, pausing loop at iteration ${state.iteration}`);
575
+ toast("Auto Loop paused — session error", "error");
576
+ // Mark inactive but keep state so user can inspect/resume
577
+ writeState(directory, { ...state, active: false }, log);
578
+ }
579
+ }
580
+
581
+ // --- session.deleted: clean up if it's our session ---
582
+ if (event.type === "session.deleted") {
583
+ const state = readState(directory);
584
+ if (!state.active) return;
585
+
586
+ const deletedSessionId = event.properties.info?.id;
587
+ if (state.sessionId && deletedSessionId && state.sessionId !== deletedSessionId) return;
588
+
589
+ clearState(directory, log);
590
+ log("info", "Session deleted, cleaning up loop state");
591
+ }
592
+ },
593
+ };
594
+ };
595
+
596
+ export default AutoLoopPlugin;