opencode-auto-loop 0.1.5 → 0.1.7

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.
@@ -4,20 +4,24 @@ description: "Start Auto Loop - auto-continues until task completion. Use: /auto
4
4
 
5
5
  # Auto Loop
6
6
 
7
- Parse `$ARGUMENTS` for the task description and an optional `--max <number>` flag.
7
+ Parse `$ARGUMENTS` for the task description and optional flags:
8
8
 
9
9
  - If `$ARGUMENTS` contains `--max <number>`, extract that number as **maxIterations** and remove it from the task string.
10
10
  - Otherwise, use **maxIterations**: 25
11
+ - If `$ARGUMENTS` contains `--ralph`, set **forceLoop** to `true` and remove it from the task string. Force mode ignores all completion signals and runs for the full maxIterations.
11
12
 
12
13
  Invoke the `auto-loop` tool with:
13
14
 
14
15
  - **task**: the extracted task description
15
16
  - **maxIterations**: the extracted or default value
17
+ - **forceLoop**: `true` if `--ralph` was present, omit otherwise
16
18
 
17
19
  Examples:
18
20
  - `/auto-loop Build a REST API` → task="Build a REST API", maxIterations=25
19
21
  - `/auto-loop Build a REST API --max 50` → task="Build a REST API", maxIterations=50
20
22
  - `/auto-loop --max 10 Fix all lint errors` → task="Fix all lint errors", maxIterations=10
23
+ - `/auto-loop --ralph Fix all lint errors` → task="Fix all lint errors", maxIterations=25, forceLoop=true
24
+ - `/auto-loop --ralph --max 10 Fix all lint errors` → task="Fix all lint errors", maxIterations=10, forceLoop=true
21
25
 
22
26
  After the tool confirms the loop is active, **immediately begin working on the task**. Do not just acknowledge — start doing the work right away.
23
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-loop",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Auto-continue for OpenCode",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -21,7 +21,9 @@ Your previous work remains accessible through files, git history, and the state
21
21
 
22
22
  ## Starting the Loop
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
+ **Use the `auto-loop` tool ONCE to start the loop.** Do NOT create the state file manually. The tool handles state file creation, session tracking, and initialization.
25
+
26
+ **CRITICAL: Do NOT call the `auto-loop` tool more than once per loop.** The plugin handles all subsequent iterations automatically via idle detection. Re-invoking the tool would be rejected to prevent accidental state resets. If you need to restart with different parameters, use `/cancel-auto-loop` first.
25
27
 
26
28
  After the tool confirms the loop is active, **immediately begin working on the task**. Do not just acknowledge — start doing the work.
27
29
 
@@ -78,6 +80,16 @@ The loop can only be stopped by:
78
80
  2. Max iterations reached
79
81
  3. User running `/cancel-auto-loop`
80
82
 
83
+ ## Force Mode (--ralph)
84
+
85
+ When started with `--ralph`, the loop ignores ALL completion signals (`<promise>DONE</promise>`, `STATUS: COMPLETE`) and runs for the full iteration count. In force mode:
86
+ - You do NOT need to output `STATUS:` lines or the DONE signal
87
+ - The loop will continue for all iterations regardless
88
+ - Focus on making steady progress each iteration
89
+ - Still output `## Completed` and `## Next Steps` sections so the plugin can track progress
90
+
91
+ Example: `/auto-loop --ralph --max 10 Continue the refactoring task`
92
+
81
93
  ## Checking Status
82
94
 
83
95
  Check current iteration and progress:
@@ -19,6 +19,15 @@ Example:
19
19
 
20
20
  The AI will work on your task and automatically continue until completion.
21
21
 
22
+ ### `/auto-loop <task> --ralph`
23
+ Force mode: ignore all completion signals and run for the full iteration count. Useful when you got interrupted and want to resume, or when you want the AI to keep iterating without stopping early.
24
+
25
+ Examples:
26
+ ```
27
+ /auto-loop --ralph Continue the refactoring
28
+ /auto-loop --ralph --max 10 Fix all lint errors
29
+ ```
30
+
22
31
  ### `/cancel-auto-loop`
23
32
  Cancel an active Auto Loop before it completes.
24
33
 
@@ -35,7 +44,7 @@ Show plugin help and available commands.
35
44
  1. **Start**: `/auto-loop` creates a state file at `.opencode/auto-loop.local.md`
36
45
  2. **Loop**: When the AI goes idle, the plugin checks if `<promise>DONE</promise>` was output
37
46
  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
47
+ 4. **Stop**: Loop continues until DONE is found or max iterations (default: 25) reached
39
48
  5. **Cleanup**: State file is deleted when complete
40
49
  6. **Compaction**: Loop context survives session compaction — task and iteration info is preserved
41
50
 
@@ -9,33 +9,19 @@ Stop an active Auto Loop before completion.
9
9
 
10
10
  ## How to Use
11
11
 
12
- When you invoke this skill:
12
+ When you need to cancel the loop, invoke the `cancel-auto-loop` tool. The tool will:
13
13
 
14
- 1. First, check if a loop is active:
14
+ 1. Check if a loop is currently active
15
+ 2. Report how many iterations were completed
16
+ 3. Clean up the state file
15
17
 
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.
18
+ **That's it.** Just call the `cancel-auto-loop` tool. Do NOT manually delete the state file.
33
19
 
34
20
  ## When to Use
35
21
 
36
22
  Use this command when:
37
23
  - The task requirements have changed
38
- - You want to restart with different parameters
24
+ - You want to restart with different parameters (cancel first, then `/auto-loop` again)
39
25
  - The loop appears stuck and you want manual control
40
26
  - You need to work on something else
41
27
 
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ interface LoopState {
16
16
  iteration: number;
17
17
  maxIterations: number;
18
18
  debounceMs: number;
19
+ forceLoop?: boolean;
19
20
  sessionId?: string;
20
21
  prompt?: string;
21
22
  completed?: string;
@@ -106,7 +107,7 @@ function getStateFile(directory: string): string {
106
107
 
107
108
  // Parse markdown frontmatter state
108
109
  function parseState(content: string): LoopState {
109
- const match = content.match(/^---\n([\s\S]*?)\n---/);
110
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
110
111
  if (!match) return { active: false, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, debounceMs: DEFAULT_DEBOUNCE_MS };
111
112
 
112
113
  const frontmatter = match[1];
@@ -117,13 +118,23 @@ function parseState(content: string): LoopState {
117
118
  debounceMs: DEFAULT_DEBOUNCE_MS,
118
119
  };
119
120
 
120
- for (const line of frontmatter.split("\n")) {
121
+ for (const line of frontmatter.split(/\r?\n/)) {
121
122
  const [key, ...valueParts] = line.split(":");
122
123
  const value = valueParts.join(":").trim();
123
124
  if (key === "active") state.active = value === "true";
124
- if (key === "iteration") state.iteration = parseInt(value) || 0;
125
- if (key === "maxIterations") state.maxIterations = parseInt(value) || DEFAULT_MAX_ITERATIONS;
126
- if (key === "debounceMs") state.debounceMs = parseInt(value) || DEFAULT_DEBOUNCE_MS;
125
+ if (key === "iteration") {
126
+ const parsed = parseInt(value, 10);
127
+ state.iteration = Number.isNaN(parsed) ? 0 : Math.max(0, parsed);
128
+ }
129
+ if (key === "maxIterations") {
130
+ const parsed = parseInt(value, 10);
131
+ state.maxIterations = (Number.isFinite(parsed) && parsed > 0) ? parsed : DEFAULT_MAX_ITERATIONS;
132
+ }
133
+ if (key === "debounceMs") {
134
+ const parsed = parseInt(value, 10);
135
+ state.debounceMs = (Number.isFinite(parsed) && parsed >= 0) ? parsed : DEFAULT_DEBOUNCE_MS;
136
+ }
137
+ if (key === "forceLoop") state.forceLoop = value === "true";
127
138
  if (key === "sessionId") state.sessionId = value || undefined;
128
139
  }
129
140
 
@@ -159,6 +170,7 @@ function serializeState(state: LoopState): string {
159
170
  `maxIterations: ${state.maxIterations}`,
160
171
  `debounceMs: ${state.debounceMs}`,
161
172
  ];
173
+ if (state.forceLoop) lines.push(`forceLoop: ${state.forceLoop}`);
162
174
  if (state.sessionId) lines.push(`sessionId: ${state.sessionId}`);
163
175
  lines.push("---");
164
176
  if (state.prompt) lines.push("", state.prompt);
@@ -244,9 +256,12 @@ async function getLastAssistantText(
244
256
  }
245
257
  }
246
258
 
247
- // Check completion by looking for <promise>DONE</promise> in last assistant text
259
+ // Check completion by looking for <promise>DONE</promise> OR STATUS: COMPLETE
260
+ // in the last assistant text. Either signal indicates the AI believes the task
261
+ // is finished. The validateCompletion() function further validates legitimacy.
248
262
  function checkCompletion(text: string): boolean {
249
- return COMPLETION_TAG.test(stripCodeFences(text));
263
+ const cleaned = stripCodeFences(text);
264
+ return COMPLETION_TAG.test(cleaned) || STATUS_COMPLETE_TAG.test(cleaned);
250
265
  }
251
266
 
252
267
  // Extract the STATUS signal presence from text.
@@ -412,16 +427,26 @@ function buildProgressSection(state: LoopState): string {
412
427
  // Build the loop context reminder for post-compaction injection
413
428
  function buildLoopContextReminder(state: LoopState): string {
414
429
  const progress = buildProgressSection(state);
415
- return `[AUTO LOOP ACTIVE Iteration ${state.iteration}/${state.maxIterations}]
416
-
417
- Original task: ${state.prompt || "(no task specified)"}
418
- ${progress}
419
- IMPORTANT RULES:
430
+ const forceLabel = state.forceLoop ? " [FORCE MODE]" : "";
431
+ const rules = state.forceLoop
432
+ ? `IMPORTANT RULES:
433
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
434
+ - Before going idle, output ## Completed and ## Next Steps sections
435
+ - FORCE MODE is active — the loop will continue for all ${state.maxIterations} iterations regardless of completion signals
436
+ - Focus on making steady progress each iteration`
437
+ : `IMPORTANT RULES:
438
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
420
439
  - Before going idle, output ## Completed and ## Next Steps sections
421
440
  - You MUST include a STATUS line: either \`STATUS: IN_PROGRESS\` or \`STATUS: COMPLETE\` on its own line
422
441
  - Do NOT output <promise>DONE</promise> if there are ANY unchecked items (\`- [ ]\`) in your Next Steps — the plugin WILL reject it
423
442
  - Only output \`STATUS: COMPLETE\` and the DONE signal when ALL steps are truly finished and Next Steps is empty
424
443
  - Do NOT output false completion promises. If blocked, output \`STATUS: IN_PROGRESS\` and explain the blocker.`;
444
+
445
+ return `[AUTO LOOP${forceLabel} ACTIVE — Iteration ${state.iteration}/${state.maxIterations}]
446
+
447
+ Original task: ${state.prompt || "(no task specified)"}
448
+ ${progress}
449
+ ${rules}`;
425
450
  }
426
451
 
427
452
  // Check if session is currently busy (not idle)
@@ -481,11 +506,10 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
481
506
 
482
507
  // Debounce tracking for idle events
483
508
  let lastContinuation = 0;
484
- // Guard: prevent sending while a continuation is already in-flight.
485
- // Set to true when we send promptAsync, cleared when we receive a
486
- // session.idle or session.status(idle) event NOT in the finally block,
487
- // which fires too early (~50ms after the 204, while AI is still busy).
488
- let continuationInFlight = false;
509
+ // Reentrant guard: prevent concurrent idle event processing.
510
+ // Two idle events can interleave at await points, causing duplicate
511
+ // state reads/writes. This ensures only one handler runs at a time.
512
+ let handlingIdle = false;
489
513
 
490
514
  return {
491
515
  tool: {
@@ -504,33 +528,68 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
504
528
  .number()
505
529
  .optional()
506
530
  .describe("Debounce delay between iterations in ms (default: 2000)"),
531
+ forceLoop: tool.schema
532
+ .boolean()
533
+ .optional()
534
+ .describe("Force mode (--ralph): ignore completion signals and run for all iterations"),
507
535
  },
508
- async execute({ task, maxIterations = DEFAULT_MAX_ITERATIONS, debounceMs = DEFAULT_DEBOUNCE_MS }, context) {
536
+ async execute({ task, maxIterations = DEFAULT_MAX_ITERATIONS, debounceMs = DEFAULT_DEBOUNCE_MS, forceLoop = false }, context) {
509
537
  if (context.abort.aborted) return "Auto Loop start was cancelled.";
510
538
 
539
+ // Coerce parameters to correct types (AI may pass strings)
540
+ const parsedMax = parseInt(String(maxIterations), 10);
541
+ const safeMaxIterations = (typeof maxIterations === "number" && maxIterations > 0)
542
+ ? maxIterations
543
+ : (Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : DEFAULT_MAX_ITERATIONS);
544
+ const parsedDebounce = parseInt(String(debounceMs), 10);
545
+ const safeDebounceMs = (typeof debounceMs === "number" && debounceMs >= 0)
546
+ ? debounceMs
547
+ : (Number.isFinite(parsedDebounce) && parsedDebounce >= 0 ? parsedDebounce : DEFAULT_DEBOUNCE_MS);
548
+
549
+ // Guard: if a loop is already active for this session, do NOT reset state.
550
+ // This prevents the AI from accidentally re-invoking the tool each iteration
551
+ // (which would reset the iteration counter back to 0 every time).
552
+ const existingState = await readState(directory);
553
+ if (existingState.active && existingState.sessionId === context.sessionID) {
554
+ log("warn", `Tool re-invoked while loop already active (iteration ${existingState.iteration}/${existingState.maxIterations}). Ignoring to prevent state reset.`);
555
+ return `Auto Loop is ALREADY ACTIVE (iteration ${existingState.iteration}/${existingState.maxIterations}). Do NOT call the auto-loop tool again — the plugin handles continuation automatically. Just keep working on the task. Use /cancel-auto-loop first if you need to restart with different parameters.`;
556
+ }
557
+ if (existingState.active && existingState.sessionId && existingState.sessionId !== context.sessionID) {
558
+ log("warn", `Starting new loop in session ${context.sessionID}, overwriting active loop from session ${existingState.sessionId} (was at iteration ${existingState.iteration}/${existingState.maxIterations})`);
559
+ toast(`Auto Loop: replacing active loop from another session`, "warning");
560
+ }
561
+
511
562
  const state: LoopState = {
512
563
  active: true,
513
564
  iteration: 0,
514
- maxIterations,
515
- debounceMs,
565
+ maxIterations: safeMaxIterations,
566
+ debounceMs: safeDebounceMs,
567
+ forceLoop: forceLoop ? true : undefined,
516
568
  sessionId: context.sessionID,
517
569
  prompt: task,
518
570
  };
519
571
  await writeState(directory, state, log);
520
572
  // Reset guards so the first idle event is not blocked
521
- continuationInFlight = false;
573
+ handlingIdle = false;
522
574
  lastContinuation = 0;
523
575
 
524
- log("info", `Loop started for session ${context.sessionID}`);
525
- toast(`Auto Loop started (max ${maxIterations} iterations)`, "success");
576
+ const modeLabel = forceLoop ? " [FORCE MODE]" : "";
577
+ log("info", `Loop started${modeLabel} for session ${context.sessionID}`);
578
+ toast(`Auto Loop started${modeLabel} (max ${safeMaxIterations} iterations)`, "success");
579
+
580
+ const forceNote = forceLoop
581
+ ? `\n\n**FORCE MODE (--ralph):** Completion signals are IGNORED. The loop will run for all ${safeMaxIterations} iterations regardless. Focus on making steady progress each iteration — you do NOT need to output STATUS or DONE signals.`
582
+ : "";
526
583
 
527
- return `Auto Loop started (max ${maxIterations} iterations).
584
+ return `Auto Loop started (max ${safeMaxIterations} iterations).${forceNote}
528
585
 
529
586
  Task: ${task}
530
587
 
531
- **Begin working on the task now.** The loop will auto-continue until you signal completion.
588
+ **Begin working on the task now.** The loop will auto-continue until ${forceLoop ? `all ${safeMaxIterations} iterations are used` : "you signal completion"}.
532
589
 
533
- Before going idle each iteration, output structured progress AND a status line:
590
+ **CRITICAL: Do NOT call the auto-loop tool again.** The plugin handles auto-continuation automatically via idle detection. Re-invoking the tool would reset your progress. Just work on the task — the plugin will prompt you to continue when you go idle.
591
+
592
+ Before going idle each iteration, output structured progress${forceLoop ? "" : " AND a status line"}:
534
593
 
535
594
  \`\`\`
536
595
  ## Completed
@@ -538,10 +597,9 @@ Before going idle each iteration, output structured progress AND a status line:
538
597
 
539
598
  ## Next Steps
540
599
  - [ ] What remains (in priority order)
541
-
542
- STATUS: IN_PROGRESS
600
+ ${forceLoop ? "" : "\nSTATUS: IN_PROGRESS"}
543
601
  \`\`\`
544
-
602
+ ${forceLoop ? "" : `
545
603
  ## Completion Rules — READ CAREFULLY
546
604
 
547
605
  1. **If your Next Steps list has ANY unchecked items (\`- [ ]\`), you MUST NOT output the DONE signal.** The plugin will reject it.
@@ -550,7 +608,7 @@ STATUS: IN_PROGRESS
550
608
  - \`STATUS: COMPLETE\` on its own line
551
609
  - The promise-DONE XML tag on its own line
552
610
  4. If you are blocked or stuck, output \`STATUS: IN_PROGRESS\` and explain the blocker. Do NOT output a false DONE.
553
-
611
+ `}
554
612
  Use /cancel-auto-loop to stop early.`;
555
613
  },
556
614
  }),
@@ -566,7 +624,8 @@ Use /cancel-auto-loop to stop early.`;
566
624
  }
567
625
  const iterations = state.iteration;
568
626
  await clearState(directory, log);
569
- continuationInFlight = false;
627
+ handlingIdle = false;
628
+ lastContinuation = 0;
570
629
 
571
630
  log("info", `Loop cancelled after ${iterations} iteration(s)`);
572
631
  toast(`Auto Loop cancelled after ${iterations} iteration(s)`, "warning");
@@ -585,6 +644,8 @@ Use /cancel-auto-loop to stop early.`;
585
644
 
586
645
  - \`/auto-loop <task>\` - Start an auto-continuation loop (default: 25 iterations)
587
646
  - \`/auto-loop <task> --max <n>\` - Start with a custom iteration limit
647
+ - \`/auto-loop <task> --ralph\` - Force mode: ignore completion signals, run all iterations
648
+ - \`/auto-loop <task> --ralph --max <n>\` - Force mode with custom limit
588
649
  - \`/cancel-auto-loop\` - Stop an active loop
589
650
  - \`/auto-loop-help\` - Show this help
590
651
 
@@ -592,6 +653,8 @@ Use /cancel-auto-loop to stop early.`;
592
653
 
593
654
  - \`/auto-loop Build a REST API\` — runs up to 25 iterations
594
655
  - \`/auto-loop Fix all lint errors --max 10\` — runs up to 10 iterations
656
+ - \`/auto-loop --ralph Continue refactoring\` — force runs all 25 iterations, ignores DONE signals
657
+ - \`/auto-loop --ralph --max 50 Big migration\` — force runs all 50 iterations
595
658
 
596
659
  ## How It Works
597
660
 
@@ -600,6 +663,13 @@ Use /cancel-auto-loop to stop early.`;
600
663
  3. Plugin auto-continues if not complete
601
664
  4. Loop stops when AI outputs: <promise>DONE</promise>
602
665
 
666
+ ## Force Mode (--ralph)
667
+
668
+ When \`--ralph\` is used, the loop ignores ALL completion signals and runs for the full iteration count. Useful when:
669
+ - You got interrupted and want to continue no matter what
670
+ - You want the AI to keep iterating and improving
671
+ - You don't want the AI to stop early
672
+
603
673
  ## State File
604
674
 
605
675
  Located at: .opencode/auto-loop.local.md`;
@@ -613,107 +683,158 @@ Located at: .opencode/auto-loop.local.md`;
613
683
  if (event.type === "session.idle") {
614
684
  const sessionId = event.properties.sessionID;
615
685
 
616
- // Session confirmed idle safe to clear in-flight guard
617
- continuationInFlight = false;
618
-
619
- const state = await readState(directory);
620
-
621
- if (!state.active) return;
622
- if (!sessionId) return;
623
- if (state.sessionId && state.sessionId !== sessionId) return;
686
+ // Reentrant guard: if we're already processing an idle event,
687
+ // skip this one. Two idle events can interleave at await points,
688
+ // causing duplicate state reads/writes and double continuations.
689
+ if (handlingIdle) {
690
+ log("debug", "Idle handler already running, skipping duplicate event");
691
+ return;
692
+ }
693
+ handlingIdle = true;
624
694
 
625
- const now = Date.now();
626
- if (now - lastContinuation < state.debounceMs) return;
695
+ try {
696
+ const state = await readState(directory);
627
697
 
628
- // Double-check the session is truly idle before sending
629
- if (await isSessionBusy(client, sessionId, log)) return;
698
+ if (!state.active) return;
699
+ if (!sessionId) return;
700
+ if (state.sessionId && state.sessionId !== sessionId) return;
701
+
702
+ const now = Date.now();
703
+ if (now - lastContinuation < state.debounceMs) return;
704
+
705
+ // Double-check the session is truly idle before sending
706
+ if (await isSessionBusy(client, sessionId, log)) return;
707
+
708
+ // Fetch last assistant message (used for completion check + progress extraction)
709
+ const lastText = await getLastAssistantText(client, sessionId, directory, log);
710
+
711
+ // Skip completion check on iteration 0 (first idle after loop start)
712
+ // to avoid false positives from the tool's initial response text.
713
+ // Also skip entirely when forceLoop is true — force mode ignores
714
+ // all completion signals and runs until max iterations.
715
+ if (!state.forceLoop && state.iteration > 0 && lastText && checkCompletion(lastText)) {
716
+ // Validate the DONE signal — reject if there are unchecked steps
717
+ // or if the STATUS signal contradicts completion
718
+ const validation = validateCompletion(lastText);
719
+ if (validation.valid) {
720
+ await clearState(directory, log);
721
+ log("info", `Loop completed at iteration ${state.iteration}`);
722
+ toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
723
+ return;
724
+ } else {
725
+ log("warn", `Rejected premature DONE signal: ${validation.reason}`);
726
+ toast(`Auto Loop: DONE rejected — ${validation.reason}`, "warning");
727
+ // Fall through to send another continuation prompt
728
+ }
729
+ }
630
730
 
631
- // Fetch last assistant message (used for completion check + progress extraction)
632
- const lastText = await getLastAssistantText(client, sessionId, directory, log);
731
+ // Heuristic: if the AI has no remaining work, stop the loop even
732
+ // without an explicit DONE/STATUS signal. This catches cases where
733
+ // the AI says "all done" naturally without the structured tag.
734
+ // Conditions: not force mode, at least 1 iteration done, the last
735
+ // response has no unchecked steps, AND the persisted state also has
736
+ // no unchecked next steps.
737
+ if (!state.forceLoop && state.iteration > 0 && lastText) {
738
+ const responseHasWork = hasIncompleteSteps(lastText);
739
+ const stateHasWork = state.nextSteps
740
+ ? /^\s*-\s*\[ \]/m.test(state.nextSteps)
741
+ : false;
742
+ const { hasInProgress } = getStatusSignals(lastText);
743
+
744
+ // No unchecked steps anywhere AND no explicit IN_PROGRESS signal
745
+ if (!responseHasWork && !stateHasWork && !hasInProgress) {
746
+ // Extra guard: only trigger if the response doesn't introduce
747
+ // new next-steps content (even without checkboxes)
748
+ const newSteps = extractNextSteps(lastText);
749
+ const hasNewWork = newSteps && newSteps.trim().length > 0;
750
+ if (!hasNewWork) {
751
+ await clearState(directory, log);
752
+ log("info", `Loop completed at iteration ${state.iteration} (no remaining work detected)`);
753
+ toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
754
+ return;
755
+ }
756
+ }
757
+ }
633
758
 
634
- // Skip completion check on iteration 0 (first idle after loop start)
635
- // to avoid false positives from the tool's initial response text
636
- if (state.iteration > 0 && lastText && checkCompletion(lastText)) {
637
- // Validate the DONE signal — reject if there are unchecked steps
638
- // or if the STATUS signal contradicts completion
639
- const validation = validateCompletion(lastText);
640
- if (validation.valid) {
759
+ if (state.iteration >= state.maxIterations) {
641
760
  await clearState(directory, log);
642
- log("info", `Loop completed at iteration ${state.iteration}`);
643
- toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
761
+ log("warn", `Loop hit max iterations (${state.maxIterations})`);
762
+ toast(`Auto Loop stopped max iterations (${state.maxIterations}) reached`, "warning");
644
763
  return;
645
- } else {
646
- log("warn", `Rejected premature DONE signal: ${validation.reason}`);
647
- toast(`Auto Loop: DONE rejected — ${validation.reason}`, "warning");
648
- // Fall through to send another continuation prompt
649
764
  }
650
- }
651
-
652
- if (state.iteration >= state.maxIterations) {
653
- await clearState(directory, log);
654
- log("warn", `Loop hit max iterations (${state.maxIterations})`);
655
- toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
656
- return;
657
- }
658
765
 
659
- // Extract progress from last message and merge with existing state
660
- const newNextSteps = lastText ? extractNextSteps(lastText) : undefined;
661
- const newCompleted = lastText ? extractCompleted(lastText) : undefined;
766
+ // Extract progress from last message and merge with existing state
767
+ const newNextSteps = lastText ? extractNextSteps(lastText) : undefined;
768
+ const newCompleted = lastText ? extractCompleted(lastText) : undefined;
662
769
 
663
- const newState: LoopState = {
664
- ...state,
665
- iteration: state.iteration + 1,
666
- sessionId,
667
- nextSteps: newNextSteps || state.nextSteps,
668
- completed: mergeCompleted(state.completed, newCompleted),
669
- };
670
- await writeState(directory, newState, log);
671
- lastContinuation = Date.now();
672
-
673
- // Build continuation prompt with progress context
674
- const progressSection = buildProgressSection(newState);
770
+ const newState: LoopState = {
771
+ ...state,
772
+ iteration: state.iteration + 1,
773
+ sessionId,
774
+ nextSteps: newNextSteps || state.nextSteps,
775
+ completed: mergeCompleted(state.completed, newCompleted),
776
+ };
777
+ await writeState(directory, newState, log);
778
+ lastContinuation = Date.now();
779
+
780
+ // Verify the state write persisted correctly
781
+ const verifyState = await readState(directory);
782
+ if (verifyState.iteration !== newState.iteration) {
783
+ log("error", `State file write verification FAILED: expected iteration ${newState.iteration}, read back ${verifyState.iteration}`);
784
+ toast(`Auto Loop: state write failed! Check .opencode/ permissions.`, "error");
785
+ return;
786
+ }
675
787
 
676
- const continuationPrompt = `[AUTO LOOP ITERATION ${newState.iteration}/${newState.maxIterations}]
788
+ // Build continuation prompt with progress context
789
+ const progressSection = buildProgressSection(newState);
677
790
 
678
- Continue working on the task. Do NOT repeat work that is already done.
679
- ${progressSection}
680
- IMPORTANT:
791
+ const forceLabel = state.forceLoop ? " [FORCE MODE]" : "";
792
+ const importantRules = state.forceLoop
793
+ ? `IMPORTANT:
794
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
795
+ - Pick up from the next incomplete step below
796
+ - Before going idle, list your progress using ## Completed and ## Next Steps sections
797
+ - FORCE MODE is active — the loop will continue for all ${newState.maxIterations} iterations regardless of completion signals
798
+ - Focus on making steady progress each iteration`
799
+ : `IMPORTANT:
800
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
681
801
  - Pick up from the next incomplete step below
682
802
  - Before going idle, list your progress using ## Completed and ## Next Steps sections
683
803
  - You MUST include a STATUS line: either \`STATUS: IN_PROGRESS\` or \`STATUS: COMPLETE\` on its own line
684
804
  - Do NOT output <promise>DONE</promise> if there are ANY unchecked items (\`- [ ]\`) in your Next Steps — the plugin WILL reject it
685
805
  - Only output \`STATUS: COMPLETE\` and the DONE signal when ALL steps are truly finished and Next Steps is empty
686
- - Do not stop until the task is truly done
806
+ - Do not stop until the task is truly done`;
807
+
808
+ const continuationPrompt = `[AUTO LOOP${forceLabel} — ITERATION ${newState.iteration}/${newState.maxIterations}]
809
+
810
+ Continue working on the task. Do NOT repeat work that is already done.
811
+ Do NOT call the auto-loop tool again — the plugin handles auto-continuation.
812
+ ${progressSection}
813
+ ${importantRules}
687
814
 
688
815
  Original task:
689
816
  ${state.prompt || "(no task specified)"}`;
690
817
 
691
- try {
692
- // Use promptAsync (fire-and-forget) so the event handler returns
693
- // immediately. This allows the next session.idle event to fire
694
- // naturally when the AI finishes, enabling the loop to continue.
695
- // The synchronous prompt() blocks until the AI response completes,
696
- // which prevents subsequent idle events from being processed.
697
- continuationInFlight = true;
698
- await client.session.promptAsync({
699
- path: { id: sessionId },
700
- body: {
701
- parts: [{ type: "text", text: continuationPrompt }],
702
- },
703
- });
704
- log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
705
- toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
706
- } catch (err) {
707
- // On failure, clear the guard so the next idle event can retry
708
- continuationInFlight = false;
709
- log("error", `Failed to send continuation prompt: ${err}`);
710
- }
711
- }
712
-
713
- // --- session.status: clear in-flight guard when session returns to idle ---
714
- if (event.type === "session.status") {
715
- if (event.properties.status?.type === "idle") {
716
- continuationInFlight = false;
818
+ try {
819
+ // Use promptAsync (fire-and-forget) so the event handler returns
820
+ // immediately. This allows the next session.idle event to fire
821
+ // naturally when the AI finishes, enabling the loop to continue.
822
+ // The synchronous prompt() blocks until the AI response completes,
823
+ // which prevents subsequent idle events from being processed.
824
+ await client.session.promptAsync({
825
+ path: { id: sessionId },
826
+ body: {
827
+ parts: [{ type: "text", text: continuationPrompt }],
828
+ },
829
+ });
830
+ log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
831
+ toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
832
+ } catch (err) {
833
+ log("error", `Failed to send continuation prompt: ${err}`);
834
+ toast(`Auto Loop: failed to send continuation ${err}`, "error");
835
+ }
836
+ } finally {
837
+ handlingIdle = false;
717
838
  }
718
839
  }
719
840
 
@@ -755,7 +876,8 @@ ${state.prompt || "(no task specified)"}`;
755
876
  ) {
756
877
  log("warn", `Session error detected, pausing loop at iteration ${state.iteration}`);
757
878
  toast("Auto Loop paused — session error", "error");
758
- continuationInFlight = false;
879
+ handlingIdle = false;
880
+ lastContinuation = 0;
759
881
  // Mark inactive but keep state so user can inspect/resume
760
882
  await writeState(directory, { ...state, active: false }, log);
761
883
  }
@@ -770,7 +892,8 @@ ${state.prompt || "(no task specified)"}`;
770
892
  if (state.sessionId && deletedSessionId && state.sessionId !== deletedSessionId) return;
771
893
 
772
894
  await clearState(directory, log);
773
- continuationInFlight = false;
895
+ handlingIdle = false;
896
+ lastContinuation = 0;
774
897
  log("info", "Session deleted, cleaning up loop state");
775
898
  }
776
899
  },