opencode-auto-loop 0.1.2 → 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.
@@ -1,13 +1,23 @@
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
- Invoke the `auto-loop` tool with the following arguments:
7
+ Parse `$ARGUMENTS` for the task description and an optional `--max <number>` flag.
8
8
 
9
- - **task**: $ARGUMENTS
10
- - **maxIterations**: 100
9
+ - If `$ARGUMENTS` contains `--max <number>`, extract that number as **maxIterations** and remove it from the task string.
10
+ - Otherwise, use **maxIterations**: 25
11
+
12
+ Invoke the `auto-loop` tool with:
13
+
14
+ - **task**: the extracted task description
15
+ - **maxIterations**: the extracted or default value
16
+
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
11
21
 
12
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.
13
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-loop",
3
- "version": "0.1.2",
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": "^0.15.31"
30
+ "@opencode-ai/plugin": "^1.2.27"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=18.0.0"
@@ -86,7 +86,7 @@ The state file at `.opencode/auto-loop.local.md` uses YAML frontmatter with prog
86
86
  ---
87
87
  active: true
88
88
  iteration: 3
89
- maxIterations: 100
89
+ maxIterations: 25
90
90
  sessionId: ses_abc123
91
91
  ---
92
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;
@@ -31,7 +31,8 @@ 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
33
  const COMPLETION_TAG = /^\s*<promise>\s*DONE\s*<\/promise>\s*$/im;
34
- const DEBOUNCE_MS = 2000;
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: 100 };
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: 100,
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) || 100;
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,32 +166,37 @@ 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
- if (existsSync(stateFile)) {
168
- return parseState(readFileSync(stateFile, "utf-8"));
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
- mkdirSync(dirname(stateFile), { recursive: true });
178
- writeFileSync(stateFile, serializeState(state));
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
- if (existsSync(stateFile)) unlinkSync(stateFile);
194
+ await unlink(stateFile);
189
195
  } catch (err) {
190
- log("warn", `Failed to clear state: ${err}`);
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
 
@@ -198,7 +207,8 @@ function stripCodeFences(text: string): string {
198
207
  .replace(/`[^`]+`/g, ""); // inline backtick code
199
208
  }
200
209
 
201
- // 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.
202
212
  async function getLastAssistantText(
203
213
  client: OpencodeClient,
204
214
  sessionId: string,
@@ -208,7 +218,7 @@ async function getLastAssistantText(
208
218
  try {
209
219
  const response = await client.session.messages({
210
220
  path: { id: sessionId },
211
- query: { directory },
221
+ query: { directory, limit: 10 },
212
222
  });
213
223
 
214
224
  const messages = response.data ?? [];
@@ -350,6 +360,27 @@ Before going idle, list your progress using ## Completed and ## Next Steps secti
350
360
  Do NOT output false completion promises. If blocked, explain the blocker.`;
351
361
  }
352
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
+
353
384
  // Main plugin
354
385
  export const AutoLoopPlugin: Plugin = async (ctx) => {
355
386
  const directory = ctx.directory || process.cwd();
@@ -386,6 +417,11 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
386
417
 
387
418
  // Debounce tracking for idle events
388
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;
389
425
 
390
426
  return {
391
427
  tool: {
@@ -399,17 +435,27 @@ export const AutoLoopPlugin: Plugin = async (ctx) => {
399
435
  maxIterations: tool.schema
400
436
  .number()
401
437
  .optional()
402
- .describe("Maximum iterations (default: 100)"),
438
+ .describe("Maximum iterations (default: 25)"),
439
+ debounceMs: tool.schema
440
+ .number()
441
+ .optional()
442
+ .describe("Debounce delay between iterations in ms (default: 2000)"),
403
443
  },
404
- async execute({ task, maxIterations = 100 }, context) {
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
+
405
447
  const state: LoopState = {
406
448
  active: true,
407
449
  iteration: 0,
408
450
  maxIterations,
451
+ debounceMs,
409
452
  sessionId: context.sessionID,
410
453
  prompt: task,
411
454
  };
412
- 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;
413
459
 
414
460
  log("info", `Loop started for session ${context.sessionID}`);
415
461
  toast(`Auto Loop started (max ${maxIterations} iterations)`, "success");
@@ -439,13 +485,15 @@ Use /cancel-auto-loop to stop early.`;
439
485
  "cancel-auto-loop": tool({
440
486
  description: "Cancel active Auto Loop",
441
487
  args: {},
442
- async execute() {
443
- const state = readState(directory);
488
+ async execute(_args, context) {
489
+ if (context.abort.aborted) return "Cancel was aborted.";
490
+ const state = await readState(directory);
444
491
  if (!state.active) {
445
492
  return "No active Auto Loop to cancel.";
446
493
  }
447
494
  const iterations = state.iteration;
448
- clearState(directory, log);
495
+ await clearState(directory, log);
496
+ continuationInFlight = false;
449
497
 
450
498
  log("info", `Loop cancelled after ${iterations} iteration(s)`);
451
499
  toast(`Auto Loop cancelled after ${iterations} iteration(s)`, "warning");
@@ -462,10 +510,16 @@ Use /cancel-auto-loop to stop early.`;
462
510
 
463
511
  ## Available Commands
464
512
 
465
- - \`/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
466
515
  - \`/cancel-auto-loop\` - Stop an active loop
467
516
  - \`/auto-loop-help\` - Show this help
468
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
+
469
523
  ## How It Works
470
524
 
471
525
  1. Start with: /auto-loop "Build a REST API"
@@ -484,30 +538,37 @@ Located at: .opencode/auto-loop.local.md`;
484
538
  event: async ({ event }) => {
485
539
  // --- session.idle: core auto-continuation logic ---
486
540
  if (event.type === "session.idle") {
487
- const now = Date.now();
488
- if (now - lastContinuation < DEBOUNCE_MS) return;
489
-
490
541
  const sessionId = event.properties.sessionID;
491
- const state = readState(directory);
542
+
543
+ // Session confirmed idle — safe to clear in-flight guard
544
+ continuationInFlight = false;
545
+
546
+ const state = await readState(directory);
492
547
 
493
548
  if (!state.active) return;
494
549
  if (!sessionId) return;
495
550
  if (state.sessionId && state.sessionId !== sessionId) return;
496
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
+
497
558
  // Fetch last assistant message (used for completion check + progress extraction)
498
559
  const lastText = await getLastAssistantText(client, sessionId, directory, log);
499
560
 
500
561
  // Skip completion check on iteration 0 (first idle after loop start)
501
562
  // to avoid false positives from the tool's initial response text
502
563
  if (state.iteration > 0 && lastText && checkCompletion(lastText)) {
503
- clearState(directory, log);
564
+ await clearState(directory, log);
504
565
  log("info", `Loop completed at iteration ${state.iteration}`);
505
566
  toast(`Auto Loop completed after ${state.iteration} iteration(s)`, "success");
506
567
  return;
507
568
  }
508
569
 
509
570
  if (state.iteration >= state.maxIterations) {
510
- clearState(directory, log);
571
+ await clearState(directory, log);
511
572
  log("warn", `Loop hit max iterations (${state.maxIterations})`);
512
573
  toast(`Auto Loop stopped — max iterations (${state.maxIterations}) reached`, "warning");
513
574
  return;
@@ -521,12 +582,10 @@ Located at: .opencode/auto-loop.local.md`;
521
582
  ...state,
522
583
  iteration: state.iteration + 1,
523
584
  sessionId,
524
- // Update next steps if we found new ones, otherwise keep previous
525
585
  nextSteps: newNextSteps || state.nextSteps,
526
- // Merge completed: append new completed items to existing
527
586
  completed: mergeCompleted(state.completed, newCompleted),
528
587
  };
529
- writeState(directory, newState, log);
588
+ await writeState(directory, newState, log);
530
589
  lastContinuation = Date.now();
531
590
 
532
591
  // Build continuation prompt with progress context
@@ -546,7 +605,13 @@ Original task:
546
605
  ${state.prompt || "(no task specified)"}`;
547
606
 
548
607
  try {
549
- await client.session.prompt({
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({
550
615
  path: { id: sessionId },
551
616
  body: {
552
617
  parts: [{ type: "text", text: continuationPrompt }],
@@ -555,21 +620,31 @@ ${state.prompt || "(no task specified)"}`;
555
620
  log("info", `Sent continuation ${newState.iteration}/${newState.maxIterations}`);
556
621
  toast(`Auto Loop: iteration ${newState.iteration}/${newState.maxIterations}`);
557
622
  } catch (err) {
623
+ // On failure, clear the guard so the next idle event can retry
624
+ continuationInFlight = false;
558
625
  log("error", `Failed to send continuation prompt: ${err}`);
559
626
  }
560
627
  }
561
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
+
562
636
  // --- session.compacted: re-inject loop context after compaction ---
563
637
  if (event.type === "session.compacted") {
564
638
  const sessionId = event.properties.sessionID;
565
- const state = readState(directory);
639
+ const state = await readState(directory);
566
640
 
567
641
  if (!state.active) return;
568
642
  if (state.sessionId && state.sessionId !== sessionId) return;
569
643
 
570
644
  // After compaction, the AI loses loop context — send a reminder
645
+ // Use promptAsync so we don't block event processing
571
646
  try {
572
- await client.session.prompt({
647
+ await client.session.promptAsync({
573
648
  path: { id: sessionId },
574
649
  body: {
575
650
  parts: [{ type: "text", text: buildLoopContextReminder(state) }],
@@ -584,7 +659,11 @@ ${state.prompt || "(no task specified)"}`;
584
659
  // --- session.error: pause the loop on error ---
585
660
  if (event.type === "session.error") {
586
661
  const sessionId = event.properties.sessionID;
587
- const state = readState(directory);
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);
588
667
 
589
668
  if (
590
669
  state.active &&
@@ -592,20 +671,22 @@ ${state.prompt || "(no task specified)"}`;
592
671
  ) {
593
672
  log("warn", `Session error detected, pausing loop at iteration ${state.iteration}`);
594
673
  toast("Auto Loop paused — session error", "error");
674
+ continuationInFlight = false;
595
675
  // Mark inactive but keep state so user can inspect/resume
596
- writeState(directory, { ...state, active: false }, log);
676
+ await writeState(directory, { ...state, active: false }, log);
597
677
  }
598
678
  }
599
679
 
600
680
  // --- session.deleted: clean up if it's our session ---
601
681
  if (event.type === "session.deleted") {
602
- const state = readState(directory);
682
+ const state = await readState(directory);
603
683
  if (!state.active) return;
604
684
 
605
685
  const deletedSessionId = event.properties.info?.id;
606
686
  if (state.sessionId && deletedSessionId && state.sessionId !== deletedSessionId) return;
607
687
 
608
- clearState(directory, log);
688
+ await clearState(directory, log);
689
+ continuationInFlight = false;
609
690
  log("info", "Session deleted, cleaning up loop state");
610
691
  }
611
692
  },