opencode-auto-loop 0.1.1 → 0.1.3
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/commands/auto-loop.md +13 -22
- package/package.json +2 -2
- package/skills/auto-loop/SKILL.md +15 -26
- package/src/index.ts +134 -47
package/commands/auto-loop.md
CHANGED
|
@@ -1,30 +1,25 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Start Auto Loop - auto-continues until task completion
|
|
2
|
+
description: "Start Auto Loop - auto-continues until task completion. Use: /auto-loop <task description>"
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Auto Loop
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Parse `$ARGUMENTS` for the task description and an optional `--max <number>` flag.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- If `$ARGUMENTS` contains `--max <number>`, extract that number as **maxIterations** and remove it from the task string.
|
|
10
|
+
- Otherwise, use **maxIterations**: 25
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Invoke the `auto-loop` tool with:
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
active: true
|
|
17
|
-
iteration: 0
|
|
18
|
-
maxIterations: 100
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
$ARGUMENTS
|
|
22
|
-
EOF
|
|
23
|
-
```
|
|
14
|
+
- **task**: the extracted task description
|
|
15
|
+
- **maxIterations**: the extracted or default value
|
|
24
16
|
|
|
25
|
-
|
|
17
|
+
Examples:
|
|
18
|
+
- `/auto-loop Build a REST API` → task="Build a REST API", maxIterations=25
|
|
19
|
+
- `/auto-loop Build a REST API --max 50` → task="Build a REST API", maxIterations=50
|
|
20
|
+
- `/auto-loop --max 10 Fix all lint errors` → task="Fix all lint errors", maxIterations=10
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
After the tool confirms the loop is active, **immediately begin working on the task**. Do not just acknowledge — start doing the work right away.
|
|
28
23
|
|
|
29
24
|
## Progress Tracking
|
|
30
25
|
|
|
@@ -38,15 +33,11 @@ Before going idle, you MUST output structured progress so the plugin knows where
|
|
|
38
33
|
- [ ] What needs to be done next (in priority order)
|
|
39
34
|
```
|
|
40
35
|
|
|
41
|
-
The plugin extracts these into the state file for the next iteration's continuation prompt.
|
|
42
|
-
|
|
43
36
|
## Completion
|
|
44
37
|
|
|
45
|
-
When the task is FULLY completed, signal completion by outputting:
|
|
38
|
+
When the task is FULLY completed, signal completion by outputting the promise-DONE XML tag on its own line:
|
|
46
39
|
|
|
47
|
-
```
|
|
48
40
|
<promise>DONE</promise>
|
|
49
|
-
```
|
|
50
41
|
|
|
51
42
|
**IMPORTANT:** ONLY output this when the task is COMPLETELY and VERIFIABLY finished. Do NOT output false promises to escape the loop.
|
|
52
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auto-loop",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Auto-continue for OpenCode",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"skills/"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@opencode-ai/plugin": "^
|
|
30
|
+
"@opencode-ai/plugin": "^1.2.27"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=18.0.0"
|
|
@@ -11,30 +11,19 @@ Start an iterative development loop that automatically continues until the task
|
|
|
11
11
|
|
|
12
12
|
The Auto Loop creates a continuous feedback cycle for completing complex tasks:
|
|
13
13
|
|
|
14
|
-
1. You
|
|
15
|
-
2.
|
|
16
|
-
3.
|
|
17
|
-
4.
|
|
14
|
+
1. You invoke the `auto-loop` tool, which creates the state file and starts the loop
|
|
15
|
+
2. You work on the task until you go idle
|
|
16
|
+
3. The plugin detects the idle state and checks for completion
|
|
17
|
+
4. If not complete, it extracts your progress and prompts you to continue
|
|
18
|
+
5. This repeats until you output the completion signal or max iterations reached
|
|
18
19
|
|
|
19
20
|
Your previous work remains accessible through files, git history, and the state file's progress sections.
|
|
20
21
|
|
|
21
22
|
## Starting the Loop
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
**Always use the `auto-loop` tool** to start the loop. Do NOT create the state file manually. The tool handles state file creation, session tracking, and initialization.
|
|
24
25
|
|
|
25
|
-
|
|
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.
|
|
26
|
+
After the tool confirms the loop is active, **immediately begin working on the task**. Do not just acknowledge — start doing the work.
|
|
38
27
|
|
|
39
28
|
## Progress Tracking - CRITICAL
|
|
40
29
|
|
|
@@ -61,9 +50,9 @@ Use this format in your final message of each iteration:
|
|
|
61
50
|
- Order ## Next Steps by priority — the continuation will tell you to start from the top
|
|
62
51
|
- The plugin extracts these sections and writes them into `auto-loop.local.md` for the next iteration
|
|
63
52
|
|
|
64
|
-
## Completion
|
|
53
|
+
## Completion Signal - CRITICAL RULES
|
|
65
54
|
|
|
66
|
-
When you have FULLY completed the task, signal completion by outputting:
|
|
55
|
+
When you have FULLY completed the task, signal completion by outputting the promise-DONE XML tag on its own line:
|
|
67
56
|
|
|
68
57
|
```
|
|
69
58
|
<promise>DONE</promise>
|
|
@@ -71,14 +60,14 @@ When you have FULLY completed the task, signal completion by outputting:
|
|
|
71
60
|
|
|
72
61
|
**IMPORTANT CONSTRAINTS:**
|
|
73
62
|
|
|
74
|
-
- ONLY output
|
|
75
|
-
- The
|
|
76
|
-
- Do NOT
|
|
77
|
-
- Do NOT
|
|
63
|
+
- ONLY output the completion signal when the task is COMPLETELY and VERIFIABLY finished
|
|
64
|
+
- The completion tag MUST be on its own line (not inline with other text)
|
|
65
|
+
- Do NOT mention or echo the completion tag in explanatory text — only output it as the actual signal
|
|
66
|
+
- Do NOT output false completion signals to escape the loop, even if you think you're stuck
|
|
78
67
|
- If you're blocked, explain the blocker and request help instead of falsely completing
|
|
79
68
|
|
|
80
69
|
The loop can only be stopped by:
|
|
81
|
-
1. Truthful completion
|
|
70
|
+
1. Truthful completion signal
|
|
82
71
|
2. Max iterations reached
|
|
83
72
|
3. User running `/cancel-auto-loop`
|
|
84
73
|
|
|
@@ -97,7 +86,7 @@ The state file at `.opencode/auto-loop.local.md` uses YAML frontmatter with prog
|
|
|
97
86
|
---
|
|
98
87
|
active: true
|
|
99
88
|
iteration: 3
|
|
100
|
-
maxIterations:
|
|
89
|
+
maxIterations: 25
|
|
101
90
|
sessionId: ses_abc123
|
|
102
91
|
---
|
|
103
92
|
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,10 @@ import { type Plugin, type PluginInput, tool } from "@opencode-ai/plugin";
|
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
readFileSync,
|
|
5
|
-
writeFileSync,
|
|
6
5
|
mkdirSync,
|
|
7
|
-
unlinkSync,
|
|
8
6
|
cpSync,
|
|
9
7
|
} from "fs";
|
|
8
|
+
import { readFile, writeFile, unlink, mkdir } from "fs/promises";
|
|
10
9
|
import { dirname, join } from "path";
|
|
11
10
|
import { fileURLToPath } from "url";
|
|
12
11
|
import { homedir } from "os";
|
|
@@ -16,6 +15,7 @@ interface LoopState {
|
|
|
16
15
|
active: boolean;
|
|
17
16
|
iteration: number;
|
|
18
17
|
maxIterations: number;
|
|
18
|
+
debounceMs: number;
|
|
19
19
|
sessionId?: string;
|
|
20
20
|
prompt?: string;
|
|
21
21
|
completed?: string;
|
|
@@ -30,8 +30,9 @@ type OpencodeClient = PluginInput["client"];
|
|
|
30
30
|
const SERVICE_NAME = "auto-loop";
|
|
31
31
|
const STATE_FILENAME = "auto-loop.local.md";
|
|
32
32
|
const OPENCODE_CONFIG_DIR = join(homedir(), ".config/opencode");
|
|
33
|
-
const COMPLETION_TAG =
|
|
34
|
-
const
|
|
33
|
+
const COMPLETION_TAG = /^\s*<promise>\s*DONE\s*<\/promise>\s*$/im;
|
|
34
|
+
const DEFAULT_DEBOUNCE_MS = 2000;
|
|
35
|
+
const DEFAULT_MAX_ITERATIONS = 25;
|
|
35
36
|
|
|
36
37
|
// Get plugin root directory (ESM only — package is "type": "module")
|
|
37
38
|
function getPluginRoot(): string {
|
|
@@ -104,13 +105,14 @@ function getStateFile(directory: string): string {
|
|
|
104
105
|
// Parse markdown frontmatter state
|
|
105
106
|
function parseState(content: string): LoopState {
|
|
106
107
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
107
|
-
if (!match) return { active: false, iteration: 0, maxIterations:
|
|
108
|
+
if (!match) return { active: false, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, debounceMs: DEFAULT_DEBOUNCE_MS };
|
|
108
109
|
|
|
109
110
|
const frontmatter = match[1];
|
|
110
111
|
const state: LoopState = {
|
|
111
112
|
active: false,
|
|
112
113
|
iteration: 0,
|
|
113
|
-
maxIterations:
|
|
114
|
+
maxIterations: DEFAULT_MAX_ITERATIONS,
|
|
115
|
+
debounceMs: DEFAULT_DEBOUNCE_MS,
|
|
114
116
|
};
|
|
115
117
|
|
|
116
118
|
for (const line of frontmatter.split("\n")) {
|
|
@@ -118,7 +120,8 @@ function parseState(content: string): LoopState {
|
|
|
118
120
|
const value = valueParts.join(":").trim();
|
|
119
121
|
if (key === "active") state.active = value === "true";
|
|
120
122
|
if (key === "iteration") state.iteration = parseInt(value) || 0;
|
|
121
|
-
if (key === "maxIterations") state.maxIterations = parseInt(value) ||
|
|
123
|
+
if (key === "maxIterations") state.maxIterations = parseInt(value) || DEFAULT_MAX_ITERATIONS;
|
|
124
|
+
if (key === "debounceMs") state.debounceMs = parseInt(value) || DEFAULT_DEBOUNCE_MS;
|
|
122
125
|
if (key === "sessionId") state.sessionId = value || undefined;
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -152,6 +155,7 @@ function serializeState(state: LoopState): string {
|
|
|
152
155
|
`active: ${state.active}`,
|
|
153
156
|
`iteration: ${state.iteration}`,
|
|
154
157
|
`maxIterations: ${state.maxIterations}`,
|
|
158
|
+
`debounceMs: ${state.debounceMs}`,
|
|
155
159
|
];
|
|
156
160
|
if (state.sessionId) lines.push(`sessionId: ${state.sessionId}`);
|
|
157
161
|
lines.push("---");
|
|
@@ -162,41 +166,49 @@ function serializeState(state: LoopState): string {
|
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
// Read state from project directory
|
|
165
|
-
function readState(directory: string): LoopState {
|
|
169
|
+
async function readState(directory: string): Promise<LoopState> {
|
|
166
170
|
const stateFile = getStateFile(directory);
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
try {
|
|
172
|
+
const content = await readFile(stateFile, "utf-8");
|
|
173
|
+
return parseState(content);
|
|
174
|
+
} catch {
|
|
175
|
+
return { active: false, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, debounceMs: DEFAULT_DEBOUNCE_MS };
|
|
169
176
|
}
|
|
170
|
-
return { active: false, iteration: 0, maxIterations: 100 };
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
// Write state to project directory
|
|
174
|
-
function writeState(directory: string, state: LoopState, log: LogFn): void {
|
|
180
|
+
async function writeState(directory: string, state: LoopState, log: LogFn): Promise<void> {
|
|
175
181
|
try {
|
|
176
182
|
const stateFile = getStateFile(directory);
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
await mkdir(dirname(stateFile), { recursive: true });
|
|
184
|
+
await writeFile(stateFile, serializeState(state));
|
|
179
185
|
} catch (err) {
|
|
180
186
|
log("error", `Failed to write state: ${err}`);
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
// Clear state
|
|
185
|
-
function clearState(directory: string, log: LogFn): void {
|
|
191
|
+
async function clearState(directory: string, log: LogFn): Promise<void> {
|
|
186
192
|
try {
|
|
187
193
|
const stateFile = getStateFile(directory);
|
|
188
|
-
|
|
194
|
+
await unlink(stateFile);
|
|
189
195
|
} catch (err) {
|
|
190
|
-
|
|
196
|
+
// ENOENT is fine — file already gone
|
|
197
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
198
|
+
log("warn", `Failed to clear state: ${err}`);
|
|
199
|
+
}
|
|
191
200
|
}
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
// Strip markdown code fences before checking for completion tag
|
|
203
|
+
// Strip markdown code fences and inline code before checking for completion tag
|
|
195
204
|
function stripCodeFences(text: string): string {
|
|
196
|
-
return text
|
|
205
|
+
return text
|
|
206
|
+
.replace(/```[\s\S]*?```/g, "") // triple-backtick blocks
|
|
207
|
+
.replace(/`[^`]+`/g, ""); // inline backtick code
|
|
197
208
|
}
|
|
198
209
|
|
|
199
|
-
// Extract text from the last assistant message in a session
|
|
210
|
+
// Extract text from the last assistant message in a session.
|
|
211
|
+
// Fetches only the most recent messages to avoid pulling the entire history.
|
|
200
212
|
async function getLastAssistantText(
|
|
201
213
|
client: OpencodeClient,
|
|
202
214
|
sessionId: string,
|
|
@@ -206,7 +218,7 @@ async function getLastAssistantText(
|
|
|
206
218
|
try {
|
|
207
219
|
const response = await client.session.messages({
|
|
208
220
|
path: { id: sessionId },
|
|
209
|
-
query: { directory },
|
|
221
|
+
query: { directory, limit: 10 },
|
|
210
222
|
});
|
|
211
223
|
|
|
212
224
|
const messages = response.data ?? [];
|
|
@@ -348,6 +360,27 @@ Before going idle, list your progress using ## Completed and ## Next Steps secti
|
|
|
348
360
|
Do NOT output false completion promises. If blocked, explain the blocker.`;
|
|
349
361
|
}
|
|
350
362
|
|
|
363
|
+
// Check if session is currently busy (not idle)
|
|
364
|
+
async function isSessionBusy(
|
|
365
|
+
client: OpencodeClient,
|
|
366
|
+
sessionId: string,
|
|
367
|
+
log: LogFn
|
|
368
|
+
): Promise<boolean> {
|
|
369
|
+
try {
|
|
370
|
+
const response = await client.session.status({});
|
|
371
|
+
const statuses = response.data ?? {};
|
|
372
|
+
const status = statuses[sessionId];
|
|
373
|
+
if (status && status.type !== "idle") {
|
|
374
|
+
log("debug", `Session ${sessionId} is ${status.type}, skipping continuation`);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log("warn", `Failed to check session status: ${err}`);
|
|
380
|
+
return false; // Assume not busy if we can't check
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
351
384
|
// Main plugin
|
|
352
385
|
export const AutoLoopPlugin: Plugin = async (ctx) => {
|
|
353
386
|
const directory = ctx.directory || process.cwd();
|
|
@@ -384,6 +417,11 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
|
|
|
384
417
|
|
|
385
418
|
// Debounce tracking for idle events
|
|
386
419
|
let lastContinuation = 0;
|
|
420
|
+
// Guard: prevent sending while a continuation is already in-flight.
|
|
421
|
+
// Set to true when we send promptAsync, cleared when we receive a
|
|
422
|
+
// session.idle or session.status(idle) event — NOT in the finally block,
|
|
423
|
+
// which fires too early (~50ms after the 204, while AI is still busy).
|
|
424
|
+
let continuationInFlight = false;
|
|
387
425
|
|
|
388
426
|
return {
|
|
389
427
|
tool: {
|
|
@@ -397,17 +435,27 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
|
|
|
397
435
|
maxIterations: tool.schema
|
|
398
436
|
.number()
|
|
399
437
|
.optional()
|
|
400
|
-
.describe("Maximum iterations (default:
|
|
438
|
+
.describe("Maximum iterations (default: 25)"),
|
|
439
|
+
debounceMs: tool.schema
|
|
440
|
+
.number()
|
|
441
|
+
.optional()
|
|
442
|
+
.describe("Debounce delay between iterations in ms (default: 2000)"),
|
|
401
443
|
},
|
|
402
|
-
async execute({ task, maxIterations =
|
|
444
|
+
async execute({ task, maxIterations = DEFAULT_MAX_ITERATIONS, debounceMs = DEFAULT_DEBOUNCE_MS }, context) {
|
|
445
|
+
if (context.abort.aborted) return "Auto Loop start was cancelled.";
|
|
446
|
+
|
|
403
447
|
const state: LoopState = {
|
|
404
448
|
active: true,
|
|
405
449
|
iteration: 0,
|
|
406
450
|
maxIterations,
|
|
451
|
+
debounceMs,
|
|
407
452
|
sessionId: context.sessionID,
|
|
408
453
|
prompt: task,
|
|
409
454
|
};
|
|
410
|
-
writeState(directory, state, log);
|
|
455
|
+
await writeState(directory, state, log);
|
|
456
|
+
// Reset guards so the first idle event is not blocked
|
|
457
|
+
continuationInFlight = false;
|
|
458
|
+
lastContinuation = 0;
|
|
411
459
|
|
|
412
460
|
log("info", `Loop started for session ${context.sessionID}`);
|
|
413
461
|
toast(`Auto Loop started (max ${maxIterations} iterations)`, "success");
|
|
@@ -416,7 +464,9 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
|
|
|
416
464
|
|
|
417
465
|
Task: ${task}
|
|
418
466
|
|
|
419
|
-
|
|
467
|
+
**Begin working on the task now.** The loop will auto-continue until you signal completion.
|
|
468
|
+
|
|
469
|
+
Before going idle each iteration, output structured progress:
|
|
420
470
|
|
|
421
471
|
\`\`\`
|
|
422
472
|
## Completed
|
|
@@ -426,7 +476,7 @@ I will auto-continue until the task is complete. Before going idle each iteratio
|
|
|
426
476
|
- [ ] What remains (in priority order)
|
|
427
477
|
\`\`\`
|
|
428
478
|
|
|
429
|
-
When
|
|
479
|
+
When the task is FULLY and VERIFIABLY complete, output the completion signal on its own line (the promise-DONE XML tag). Do NOT mention or echo the completion tag until you are truly done.
|
|
430
480
|
|
|
431
481
|
Use /cancel-auto-loop to stop early.`;
|
|
432
482
|
},
|
|
@@ -435,13 +485,15 @@ Use /cancel-auto-loop to stop early.`;
|
|
|
435
485
|
"cancel-auto-loop": tool({
|
|
436
486
|
description: "Cancel active Auto Loop",
|
|
437
487
|
args: {},
|
|
438
|
-
async execute() {
|
|
439
|
-
|
|
488
|
+
async execute(_args, context) {
|
|
489
|
+
if (context.abort.aborted) return "Cancel was aborted.";
|
|
490
|
+
const state = await readState(directory);
|
|
440
491
|
if (!state.active) {
|
|
441
492
|
return "No active Auto Loop to cancel.";
|
|
442
493
|
}
|
|
443
494
|
const iterations = state.iteration;
|
|
444
|
-
clearState(directory, log);
|
|
495
|
+
await clearState(directory, log);
|
|
496
|
+
continuationInFlight = false;
|
|
445
497
|
|
|
446
498
|
log("info", `Loop cancelled after ${iterations} iteration(s)`);
|
|
447
499
|
toast(`Auto Loop cancelled after ${iterations} iteration(s)`, "warning");
|
|
@@ -458,10 +510,16 @@ Use /cancel-auto-loop to stop early.`;
|
|
|
458
510
|
|
|
459
511
|
## Available Commands
|
|
460
512
|
|
|
461
|
-
- \`/auto-loop <task>\` - Start an auto-continuation loop
|
|
513
|
+
- \`/auto-loop <task>\` - Start an auto-continuation loop (default: 25 iterations)
|
|
514
|
+
- \`/auto-loop <task> --max <n>\` - Start with a custom iteration limit
|
|
462
515
|
- \`/cancel-auto-loop\` - Stop an active loop
|
|
463
516
|
- \`/auto-loop-help\` - Show this help
|
|
464
517
|
|
|
518
|
+
## Examples
|
|
519
|
+
|
|
520
|
+
- \`/auto-loop Build a REST API\` — runs up to 25 iterations
|
|
521
|
+
- \`/auto-loop Fix all lint errors --max 10\` — runs up to 10 iterations
|
|
522
|
+
|
|
465
523
|
## How It Works
|
|
466
524
|
|
|
467
525
|
1. Start with: /auto-loop "Build a REST API"
|
|
@@ -480,28 +538,37 @@ Located at: .opencode/auto-loop.local.md`;
|
|
|
480
538
|
event: async ({ event }) => {
|
|
481
539
|
// --- session.idle: core auto-continuation logic ---
|
|
482
540
|
if (event.type === "session.idle") {
|
|
483
|
-
const now = Date.now();
|
|
484
|
-
if (now - lastContinuation < DEBOUNCE_MS) return;
|
|
485
|
-
|
|
486
541
|
const sessionId = event.properties.sessionID;
|
|
487
|
-
|
|
542
|
+
|
|
543
|
+
// Session confirmed idle — safe to clear in-flight guard
|
|
544
|
+
continuationInFlight = false;
|
|
545
|
+
|
|
546
|
+
const state = await readState(directory);
|
|
488
547
|
|
|
489
548
|
if (!state.active) return;
|
|
490
549
|
if (!sessionId) return;
|
|
491
550
|
if (state.sessionId && state.sessionId !== sessionId) return;
|
|
492
551
|
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
if (now - lastContinuation < state.debounceMs) return;
|
|
554
|
+
|
|
555
|
+
// Double-check the session is truly idle before sending
|
|
556
|
+
if (await isSessionBusy(client, sessionId, log)) return;
|
|
557
|
+
|
|
493
558
|
// Fetch last assistant message (used for completion check + progress extraction)
|
|
494
559
|
const lastText = await getLastAssistantText(client, sessionId, directory, log);
|
|
495
560
|
|
|
496
|
-
|
|
497
|
-
|
|
561
|
+
// Skip completion check on iteration 0 (first idle after loop start)
|
|
562
|
+
// to avoid false positives from the tool's initial response text
|
|
563
|
+
if (state.iteration > 0 && lastText && checkCompletion(lastText)) {
|
|
564
|
+
await clearState(directory, log);
|
|
498
565
|
log("info", `Loop completed at iteration ${state.iteration}`);
|
|
499
566
|
toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
|
|
500
567
|
return;
|
|
501
568
|
}
|
|
502
569
|
|
|
503
570
|
if (state.iteration >= state.maxIterations) {
|
|
504
|
-
clearState(directory, log);
|
|
571
|
+
await clearState(directory, log);
|
|
505
572
|
log("warn", `Loop hit max iterations (${state.maxIterations})`);
|
|
506
573
|
toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
|
|
507
574
|
return;
|
|
@@ -515,12 +582,10 @@ Located at: .opencode/auto-loop.local.md`;
|
|
|
515
582
|
...state,
|
|
516
583
|
iteration: state.iteration + 1,
|
|
517
584
|
sessionId,
|
|
518
|
-
// Update next steps if we found new ones, otherwise keep previous
|
|
519
585
|
nextSteps: newNextSteps || state.nextSteps,
|
|
520
|
-
// Merge completed: append new completed items to existing
|
|
521
586
|
completed: mergeCompleted(state.completed, newCompleted),
|
|
522
587
|
};
|
|
523
|
-
writeState(directory, newState, log);
|
|
588
|
+
await writeState(directory, newState, log);
|
|
524
589
|
lastContinuation = Date.now();
|
|
525
590
|
|
|
526
591
|
// Build continuation prompt with progress context
|
|
@@ -540,7 +605,13 @@ Original task:
|
|
|
540
605
|
${state.prompt || "(no task specified)"}`;
|
|
541
606
|
|
|
542
607
|
try {
|
|
543
|
-
|
|
608
|
+
// Use promptAsync (fire-and-forget) so the event handler returns
|
|
609
|
+
// immediately. This allows the next session.idle event to fire
|
|
610
|
+
// naturally when the AI finishes, enabling the loop to continue.
|
|
611
|
+
// The synchronous prompt() blocks until the AI response completes,
|
|
612
|
+
// which prevents subsequent idle events from being processed.
|
|
613
|
+
continuationInFlight = true;
|
|
614
|
+
await client.session.promptAsync({
|
|
544
615
|
path: { id: sessionId },
|
|
545
616
|
body: {
|
|
546
617
|
parts: [{ type: "text", text: continuationPrompt }],
|
|
@@ -549,21 +620,31 @@ ${state.prompt || "(no task specified)"}`;
|
|
|
549
620
|
log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
|
|
550
621
|
toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
|
|
551
622
|
} catch (err) {
|
|
623
|
+
// On failure, clear the guard so the next idle event can retry
|
|
624
|
+
continuationInFlight = false;
|
|
552
625
|
log("error", `Failed to send continuation prompt: ${err}`);
|
|
553
626
|
}
|
|
554
627
|
}
|
|
555
628
|
|
|
629
|
+
// --- session.status: clear in-flight guard when session returns to idle ---
|
|
630
|
+
if (event.type === "session.status") {
|
|
631
|
+
if (event.properties.status?.type === "idle") {
|
|
632
|
+
continuationInFlight = false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
556
636
|
// --- session.compacted: re-inject loop context after compaction ---
|
|
557
637
|
if (event.type === "session.compacted") {
|
|
558
638
|
const sessionId = event.properties.sessionID;
|
|
559
|
-
const state = readState(directory);
|
|
639
|
+
const state = await readState(directory);
|
|
560
640
|
|
|
561
641
|
if (!state.active) return;
|
|
562
642
|
if (state.sessionId && state.sessionId !== sessionId) return;
|
|
563
643
|
|
|
564
644
|
// After compaction, the AI loses loop context — send a reminder
|
|
645
|
+
// Use promptAsync so we don't block event processing
|
|
565
646
|
try {
|
|
566
|
-
await client.session.
|
|
647
|
+
await client.session.promptAsync({
|
|
567
648
|
path: { id: sessionId },
|
|
568
649
|
body: {
|
|
569
650
|
parts: [{ type: "text", text: buildLoopContextReminder(state) }],
|
|
@@ -578,7 +659,11 @@ ${state.prompt || "(no task specified)"}`;
|
|
|
578
659
|
// --- session.error: pause the loop on error ---
|
|
579
660
|
if (event.type === "session.error") {
|
|
580
661
|
const sessionId = event.properties.sessionID;
|
|
581
|
-
|
|
662
|
+
// sessionID is optional in the SDK types — if missing, we can't
|
|
663
|
+
// reliably attribute the error to our session, so skip.
|
|
664
|
+
if (!sessionId) return;
|
|
665
|
+
|
|
666
|
+
const state = await readState(directory);
|
|
582
667
|
|
|
583
668
|
if (
|
|
584
669
|
state.active &&
|
|
@@ -586,20 +671,22 @@ ${state.prompt || "(no task specified)"}`;
|
|
|
586
671
|
) {
|
|
587
672
|
log("warn", `Session error detected, pausing loop at iteration ${state.iteration}`);
|
|
588
673
|
toast("Auto Loop paused — session error", "error");
|
|
674
|
+
continuationInFlight = false;
|
|
589
675
|
// Mark inactive but keep state so user can inspect/resume
|
|
590
|
-
writeState(directory, { ...state, active: false }, log);
|
|
676
|
+
await writeState(directory, { ...state, active: false }, log);
|
|
591
677
|
}
|
|
592
678
|
}
|
|
593
679
|
|
|
594
680
|
// --- session.deleted: clean up if it's our session ---
|
|
595
681
|
if (event.type === "session.deleted") {
|
|
596
|
-
const state = readState(directory);
|
|
682
|
+
const state = await readState(directory);
|
|
597
683
|
if (!state.active) return;
|
|
598
684
|
|
|
599
685
|
const deletedSessionId = event.properties.info?.id;
|
|
600
686
|
if (state.sessionId && deletedSessionId && state.sessionId !== deletedSessionId) return;
|
|
601
687
|
|
|
602
|
-
clearState(directory, log);
|
|
688
|
+
await clearState(directory, log);
|
|
689
|
+
continuationInFlight = false;
|
|
603
690
|
log("info", "Session deleted, cleaning up loop state");
|
|
604
691
|
}
|
|
605
692
|
},
|