opencode-auto-loop 0.1.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-loop",
3
- "version": "0.1.6",
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
 
@@ -44,7 +44,7 @@ Show plugin help and available commands.
44
44
  1. **Start**: `/auto-loop` creates a state file at `.opencode/auto-loop.local.md`
45
45
  2. **Loop**: When the AI goes idle, the plugin checks if `<promise>DONE</promise>` was output
46
46
  3. **Continue**: If not found, it injects "Continue from where you left off"
47
- 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
48
48
  5. **Cleanup**: State file is deleted when complete
49
49
  6. **Compaction**: Loop context survives session compaction — task and iteration info is preserved
50
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
@@ -107,7 +107,7 @@ function getStateFile(directory: string): string {
107
107
 
108
108
  // Parse markdown frontmatter state
109
109
  function parseState(content: string): LoopState {
110
- const match = content.match(/^---\n([\s\S]*?)\n---/);
110
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
111
111
  if (!match) return { active: false, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, debounceMs: DEFAULT_DEBOUNCE_MS };
112
112
 
113
113
  const frontmatter = match[1];
@@ -118,13 +118,22 @@ function parseState(content: string): LoopState {
118
118
  debounceMs: DEFAULT_DEBOUNCE_MS,
119
119
  };
120
120
 
121
- for (const line of frontmatter.split("\n")) {
121
+ for (const line of frontmatter.split(/\r?\n/)) {
122
122
  const [key, ...valueParts] = line.split(":");
123
123
  const value = valueParts.join(":").trim();
124
124
  if (key === "active") state.active = value === "true";
125
- if (key === "iteration") state.iteration = parseInt(value) || 0;
126
- if (key === "maxIterations") state.maxIterations = parseInt(value) || DEFAULT_MAX_ITERATIONS;
127
- 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
+ }
128
137
  if (key === "forceLoop") state.forceLoop = value === "true";
129
138
  if (key === "sessionId") state.sessionId = value || undefined;
130
139
  }
@@ -247,9 +256,12 @@ async function getLastAssistantText(
247
256
  }
248
257
  }
249
258
 
250
- // 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.
251
262
  function checkCompletion(text: string): boolean {
252
- return COMPLETION_TAG.test(stripCodeFences(text));
263
+ const cleaned = stripCodeFences(text);
264
+ return COMPLETION_TAG.test(cleaned) || STATUS_COMPLETE_TAG.test(cleaned);
253
265
  }
254
266
 
255
267
  // Extract the STATUS signal presence from text.
@@ -418,10 +430,12 @@ function buildLoopContextReminder(state: LoopState): string {
418
430
  const forceLabel = state.forceLoop ? " [FORCE MODE]" : "";
419
431
  const rules = state.forceLoop
420
432
  ? `IMPORTANT RULES:
433
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
421
434
  - Before going idle, output ## Completed and ## Next Steps sections
422
435
  - FORCE MODE is active — the loop will continue for all ${state.maxIterations} iterations regardless of completion signals
423
436
  - Focus on making steady progress each iteration`
424
437
  : `IMPORTANT RULES:
438
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
425
439
  - Before going idle, output ## Completed and ## Next Steps sections
426
440
  - You MUST include a STATUS line: either \`STATUS: IN_PROGRESS\` or \`STATUS: COMPLETE\` on its own line
427
441
  - Do NOT output <promise>DONE</promise> if there are ANY unchecked items (\`- [ ]\`) in your Next Steps — the plugin WILL reject it
@@ -492,11 +506,10 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
492
506
 
493
507
  // Debounce tracking for idle events
494
508
  let lastContinuation = 0;
495
- // Guard: prevent sending while a continuation is already in-flight.
496
- // Set to true when we send promptAsync, cleared when we receive a
497
- // session.idle or session.status(idle) event NOT in the finally block,
498
- // which fires too early (~50ms after the 204, while AI is still busy).
499
- 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;
500
513
 
501
514
  return {
502
515
  tool: {
@@ -523,33 +536,58 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
523
536
  async execute({ task, maxIterations = DEFAULT_MAX_ITERATIONS, debounceMs = DEFAULT_DEBOUNCE_MS, forceLoop = false }, context) {
524
537
  if (context.abort.aborted) return "Auto Loop start was cancelled.";
525
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
+
526
562
  const state: LoopState = {
527
563
  active: true,
528
564
  iteration: 0,
529
- maxIterations,
530
- debounceMs,
565
+ maxIterations: safeMaxIterations,
566
+ debounceMs: safeDebounceMs,
531
567
  forceLoop: forceLoop ? true : undefined,
532
568
  sessionId: context.sessionID,
533
569
  prompt: task,
534
570
  };
535
571
  await writeState(directory, state, log);
536
572
  // Reset guards so the first idle event is not blocked
537
- continuationInFlight = false;
573
+ handlingIdle = false;
538
574
  lastContinuation = 0;
539
575
 
540
576
  const modeLabel = forceLoop ? " [FORCE MODE]" : "";
541
577
  log("info", `Loop started${modeLabel} for session ${context.sessionID}`);
542
- toast(`Auto Loop started${modeLabel} (max ${maxIterations} iterations)`, "success");
578
+ toast(`Auto Loop started${modeLabel} (max ${safeMaxIterations} iterations)`, "success");
543
579
 
544
580
  const forceNote = forceLoop
545
- ? `\n\n**FORCE MODE (--ralph):** Completion signals are IGNORED. The loop will run for all ${maxIterations} iterations regardless. Focus on making progress each iteration — you do NOT need to output STATUS or DONE signals.`
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.`
546
582
  : "";
547
583
 
548
- return `Auto Loop started (max ${maxIterations} iterations).${forceNote}
584
+ return `Auto Loop started (max ${safeMaxIterations} iterations).${forceNote}
549
585
 
550
586
  Task: ${task}
551
587
 
552
- **Begin working on the task now.** The loop will auto-continue until ${forceLoop ? `all ${maxIterations} iterations are used` : "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"}.
589
+
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.
553
591
 
554
592
  Before going idle each iteration, output structured progress${forceLoop ? "" : " AND a status line"}:
555
593
 
@@ -586,7 +624,8 @@ Use /cancel-auto-loop to stop early.`;
586
624
  }
587
625
  const iterations = state.iteration;
588
626
  await clearState(directory, log);
589
- continuationInFlight = false;
627
+ handlingIdle = false;
628
+ lastContinuation = 0;
590
629
 
591
630
  log("info", `Loop cancelled after ${iterations} iteration(s)`);
592
631
  toast(`Auto Loop cancelled after ${iterations} iteration(s)`, "warning");
@@ -644,76 +683,121 @@ Located at: .opencode/auto-loop.local.md`;
644
683
  if (event.type === "session.idle") {
645
684
  const sessionId = event.properties.sessionID;
646
685
 
647
- // Session confirmed idle safe to clear in-flight guard
648
- continuationInFlight = false;
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;
649
694
 
650
- const state = await readState(directory);
695
+ try {
696
+ const state = await readState(directory);
651
697
 
652
- if (!state.active) return;
653
- if (!sessionId) return;
654
- if (state.sessionId && state.sessionId !== sessionId) 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
+ }
655
730
 
656
- const now = Date.now();
657
- if (now - lastContinuation < state.debounceMs) return;
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
+ }
658
758
 
659
- // Double-check the session is truly idle before sending
660
- if (await isSessionBusy(client, sessionId, log)) return;
759
+ if (state.iteration >= state.maxIterations) {
760
+ await clearState(directory, log);
761
+ log("warn", `Loop hit max iterations (${state.maxIterations})`);
762
+ toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
763
+ return;
764
+ }
661
765
 
662
- // Fetch last assistant message (used for completion check + progress extraction)
663
- const lastText = await getLastAssistantText(client, sessionId, directory, log);
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;
664
769
 
665
- // Skip completion check on iteration 0 (first idle after loop start)
666
- // to avoid false positives from the tool's initial response text.
667
- // Also skip entirely when forceLoop is true — force mode ignores
668
- // all completion signals and runs until max iterations.
669
- if (!state.forceLoop && state.iteration > 0 && lastText && checkCompletion(lastText)) {
670
- // Validate the DONE signal — reject if there are unchecked steps
671
- // or if the STATUS signal contradicts completion
672
- const validation = validateCompletion(lastText);
673
- if (validation.valid) {
674
- await clearState(directory, log);
675
- log("info", `Loop completed at iteration ${state.iteration}`);
676
- toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
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");
677
785
  return;
678
- } else {
679
- log("warn", `Rejected premature DONE signal: ${validation.reason}`);
680
- toast(`Auto Loop: DONE rejected — ${validation.reason}`, "warning");
681
- // Fall through to send another continuation prompt
682
786
  }
683
- }
684
787
 
685
- if (state.iteration >= state.maxIterations) {
686
- await clearState(directory, log);
687
- log("warn", `Loop hit max iterations (${state.maxIterations})`);
688
- toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
689
- return;
690
- }
788
+ // Build continuation prompt with progress context
789
+ const progressSection = buildProgressSection(newState);
691
790
 
692
- // Extract progress from last message and merge with existing state
693
- const newNextSteps = lastText ? extractNextSteps(lastText) : undefined;
694
- const newCompleted = lastText ? extractCompleted(lastText) : undefined;
695
-
696
- const newState: LoopState = {
697
- ...state,
698
- iteration: state.iteration + 1,
699
- sessionId,
700
- nextSteps: newNextSteps || state.nextSteps,
701
- completed: mergeCompleted(state.completed, newCompleted),
702
- };
703
- await writeState(directory, newState, log);
704
- lastContinuation = Date.now();
705
-
706
- // Build continuation prompt with progress context
707
- const progressSection = buildProgressSection(newState);
708
-
709
- const forceLabel = state.forceLoop ? " [FORCE MODE]" : "";
710
- const importantRules = state.forceLoop
711
- ? `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
712
795
  - Pick up from the next incomplete step below
713
796
  - Before going idle, list your progress using ## Completed and ## Next Steps sections
714
797
  - FORCE MODE is active — the loop will continue for all ${newState.maxIterations} iterations regardless of completion signals
715
798
  - Focus on making steady progress each iteration`
716
- : `IMPORTANT:
799
+ : `IMPORTANT:
800
+ - Do NOT call the auto-loop tool — the plugin handles continuation automatically
717
801
  - Pick up from the next incomplete step below
718
802
  - Before going idle, list your progress using ## Completed and ## Next Steps sections
719
803
  - You MUST include a STATUS line: either \`STATUS: IN_PROGRESS\` or \`STATUS: COMPLETE\` on its own line
@@ -721,41 +805,36 @@ Located at: .opencode/auto-loop.local.md`;
721
805
  - Only output \`STATUS: COMPLETE\` and the DONE signal when ALL steps are truly finished and Next Steps is empty
722
806
  - Do not stop until the task is truly done`;
723
807
 
724
- const continuationPrompt = `[AUTO LOOP${forceLabel} — ITERATION ${newState.iteration}/${newState.maxIterations}]
808
+ const continuationPrompt = `[AUTO LOOP${forceLabel} — ITERATION ${newState.iteration}/${newState.maxIterations}]
725
809
 
726
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.
727
812
  ${progressSection}
728
813
  ${importantRules}
729
814
 
730
815
  Original task:
731
816
  ${state.prompt || "(no task specified)"}`;
732
817
 
733
- try {
734
- // Use promptAsync (fire-and-forget) so the event handler returns
735
- // immediately. This allows the next session.idle event to fire
736
- // naturally when the AI finishes, enabling the loop to continue.
737
- // The synchronous prompt() blocks until the AI response completes,
738
- // which prevents subsequent idle events from being processed.
739
- continuationInFlight = true;
740
- await client.session.promptAsync({
741
- path: { id: sessionId },
742
- body: {
743
- parts: [{ type: "text", text: continuationPrompt }],
744
- },
745
- });
746
- log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
747
- toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
748
- } catch (err) {
749
- // On failure, clear the guard so the next idle event can retry
750
- continuationInFlight = false;
751
- log("error", `Failed to send continuation prompt: ${err}`);
752
- }
753
- }
754
-
755
- // --- session.status: clear in-flight guard when session returns to idle ---
756
- if (event.type === "session.status") {
757
- if (event.properties.status?.type === "idle") {
758
- 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;
759
838
  }
760
839
  }
761
840
 
@@ -797,7 +876,8 @@ ${state.prompt || "(no task specified)"}`;
797
876
  ) {
798
877
  log("warn", `Session error detected, pausing loop at iteration ${state.iteration}`);
799
878
  toast("Auto Loop paused — session error", "error");
800
- continuationInFlight = false;
879
+ handlingIdle = false;
880
+ lastContinuation = 0;
801
881
  // Mark inactive but keep state so user can inspect/resume
802
882
  await writeState(directory, { ...state, active: false }, log);
803
883
  }
@@ -812,7 +892,8 @@ ${state.prompt || "(no task specified)"}`;
812
892
  if (state.sessionId && deletedSessionId && state.sessionId !== deletedSessionId) return;
813
893
 
814
894
  await clearState(directory, log);
815
- continuationInFlight = false;
895
+ handlingIdle = false;
896
+ lastContinuation = 0;
816
897
  log("info", "Session deleted, cleaning up loop state");
817
898
  }
818
899
  },