opencode icon indicating copy to clipboard operation
opencode copied to clipboard

[FEATURE]: Delayed queue feature

Open jaresty opened this issue 1 month ago • 9 comments

Feature hasn't been suggested before.

  • [x] I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

When working with codex, I often submit the same request repeatedly in a loop that captures any learnings and updates an ADR to keep steering itself towards an outcome that I define up front. This allows me to queue up work to run overnight or when I step away from the keyboard which is useful. When I tried to do the same in opencode, it appeared that the messages I sent were queued immediately and interrupted work in progress in a way that prevented me from queuing up many messages to step away. Can you think of a way to queue a message that would wait until opencode was waiting for user input (rather than interrupting a current task)?

jaresty avatar Dec 11 '25 23:12 jaresty

This issue might be a duplicate of existing issues. Please check:

  • #5333: [FEATURE]: Graceful handling of queued messages after session interrupt - addresses message queueing and task interruption handling
  • #3098: Chained prompts executing together instead of one after the other - discusses prompt/message execution sequencing
  • #4821: [FEATURE]: Add ability to unqueue messages - related queue management feature request

Feel free to ignore if none of these address your specific case.

github-actions[bot] avatar Dec 11 '25 23:12 github-actions[bot]

I have not tested this (ai generated so consider it pseudo code) but it should work and achieve what you want in a better way than queuing the same message repeatedly:

/**
 * RepeatPromptPlugin for OpenCode
 *
 * Adds two control commands (typed as normal chat messages or as custom commands):
 *
 * 1) /loop [N] "prompt"
 *    - Starts an automated loop that repeatedly sends the same prompt.
 *    - Optional N: maximum number of iterations (e.g., /loop 10 "Update the ADR...").
 *      If no number is provided, the loop runs indefinitely until stopped.
 *    - The plugin only sends the next iteration AFTER the assistant finishes responding
 *      (it waits for the `session.idle` event, so it won't interrupt ongoing generation).
 *    - The loop automatically stops if the assistant’s most recent response contains:
 *        </STOP_LOOP>
 *      You can include logic in your prompt to emit that token when done.
 *
 * 2) /loopstop
 *    - Immediately stops any active loop for the current session.
 *    - Best-effort aborts any in-progress generation.
 *    - Shows a UI toast confirmation ("Loop stopped.").
 *
 * Notes:
 * - If you send manual messages while a loop is active, they’re processed normally.
 *   The loop will continue on the next `session.idle` unless you stop it.
 */

import type { Plugin } from "@opencode-ai/plugin";

type LoopState = {
  prompt: string;
  maxLoops: number; // Infinity = run until stopped
  sent: number; // how many times we've sent the loop prompt
  waitingForIdle: boolean; // true after we dispatch, until we see session.idle again
  stopKeyword: string;
};

export const RepeatPromptPlugin: Plugin = async ({ client }) => {
  const STOP_KEYWORD = "</STOP_LOOP>";
  const loops = new Map<string, LoopState>();

  function getPayload(evt: any): any {
    return evt?.properties ?? evt?.data ?? evt?.payload ?? {};
  }

  function getSessionId(evt: any): string | null {
    const p = getPayload(evt);
    return (
      p.session?.id ??
      p.sessionId ??
      p.sessionID ??
      p.session_id ??
      p.session ??
      null
    );
  }

  async function toast(
    message: string,
    variant: "info" | "success" | "error" = "info",
  ) {
    try {
      await client.tui.showToast({ body: { message, variant } });
    } catch {
      // Ignore if no TUI is connected
    }
  }

  function stripQuotes(s: string): string {
    const t = s.trim();
    if (t.length >= 2) {
      const a = t[0];
      const b = t[t.length - 1];
      if (
        (a === `"` && b === `"`) ||
        (a === `'` && b === `'`) ||
        (a === "`" && b === "`")
      ) {
        return t.slice(1, -1);
      }
    }
    return t;
  }

  function parseLoopCommandText(
    text: string,
  ):
    | { kind: "loop"; maxLoops: number; prompt: string }
    | { kind: "loopstop" }
    | { kind: "error"; message: string }
    | null {
    const t = text.trim();

    // Important: check /loopstop BEFORE /loop (since /loopstop starts with /loop)
    if (/^\/loopstop\b/.test(t)) return { kind: "loopstop" };
    if (!/^\/loop\b/.test(t)) return null;

    const rest = t.replace(/^\/loop\b/, "").trim();
    if (!rest) {
      return { kind: "error", message: `Usage: /loop [N] "prompt"` };
    }

    // Optional leading integer
    const m = rest.match(/^(\d+)\s+([\s\S]+)$/);
    if (m) {
      const n = Number(m[1]);
      if (!Number.isFinite(n) || n <= 0) {
        return { kind: "error", message: "Loop count must be a positive integer." };
      }
      return { kind: "loop", maxLoops: n, prompt: stripQuotes(m[2]) };
    }

    return { kind: "loop", maxLoops: Infinity, prompt: stripQuotes(rest) };
  }

  function partsToText(parts: any[]): string {
    const out: string[] = [];
    for (const part of parts ?? []) {
      if (!part) continue;
      if (typeof part === "string") {
        out.push(part);
        continue;
      }
      const t =
        (typeof part.text === "string" && part.text) ||
        (typeof part.content === "string" && part.content) ||
        (typeof part.value === "string" && part.value) ||
        "";
      if (t) out.push(t);
    }
    return out.join("");
  }

  async function lastAssistantText(sessionId: string): Promise<string> {
    try {
      const msgs = (await client.session.messages({ path: { id: sessionId } })) as any[];
      for (let i = msgs.length - 1; i >= 0; i--) {
        const row = msgs[i];
        const info = row?.info ?? row?.message ?? row;
        const role = info?.role ?? info?.type;
        if (role === "assistant") {
          return partsToText(row?.parts ?? info?.parts ?? []);
        }
      }
      return "";
    } catch {
      return "";
    }
  }

  async function stopLoop(sessionId: string, reason: string) {
    loops.delete(sessionId);
    await toast(reason, "info");
  }

  async function startLoop(sessionId: string, maxLoops: number, prompt: string) {
    if (!prompt.trim()) {
      await toast("Loop not started: prompt is empty.", "error");
      return;
    }

    loops.set(sessionId, {
      prompt,
      maxLoops,
      sent: 0,
      waitingForIdle: false,
      stopKeyword: STOP_KEYWORD,
    });

    await toast(
      maxLoops === Infinity ? "Loop started (∞)." : `Loop started (${maxLoops}).`,
      "success",
    );

    // Kick off immediately (if session is busy, we’ll retry on idle)
    void maybeSendNext(sessionId);
  }

  async function maybeSendNext(sessionId: string) {
    const st = loops.get(sessionId);
    if (!st) return;

    if (st.waitingForIdle) return;

    if (st.sent >= st.maxLoops) {
      await stopLoop(sessionId, "Loop finished (max iterations reached).");
      return;
    }

    // Check stop keyword in the latest assistant output before sending again
    const last = await lastAssistantText(sessionId);
    if (last.includes(st.stopKeyword)) {
      await stopLoop(sessionId, "Loop stopped (stop keyword detected).");
      return;
    }

    st.sent += 1;
    st.waitingForIdle = true;

    try {
      await client.session.prompt({
        path: { id: sessionId },
        body: { parts: [{ type: "text", text: st.prompt }] },
      });
    } catch (err) {
      st.sent -= 1;
      st.waitingForIdle = false;
      await toast(
        `Loop send failed; will retry on idle. (${(err as any)?.message ?? String(err)})`,
        "error",
      );
    }
  }

  return {
    event: async ({ event }) => {
      const type = event.type;
      const sessionId = getSessionId(event);
      if (!sessionId) return;

      if (type === "message.updated") {
        const p = getPayload(event);

        const container = p.message ?? p;
        const info = container.info ?? container;
        const role = info.role ?? info.type;

        let text = "";
        if (typeof container.content === "string") text = container.content;
        else if (typeof info.content === "string") text = info.content;
        else if (Array.isArray(container.parts)) text = partsToText(container.parts);
        else if (Array.isArray(p.parts)) text = partsToText(p.parts);

        if (!text) return;

        // Stop keyword (assistant)
        if (role === "assistant" && text.includes(STOP_KEYWORD)) {
          await stopLoop(sessionId, "Loop stopped (stop keyword detected).");
          return;
        }

        // Control commands (user)
        if (role === "user") {
          const parsed = parseLoopCommandText(text);
          if (!parsed) return;

          if (parsed.kind === "error") {
            await toast(parsed.message, "error");
            return;
          }

          if (parsed.kind === "loopstop") {
            if (loops.has(sessionId)) {
              loops.delete(sessionId);
              try {
                await client.session.abort({ path: { id: sessionId } });
              } catch {}
              await toast("Loop stopped.", "success");
            } else {
              await toast("No active loop to stop.", "info");
            }
            return;
          }

          if (parsed.kind === "loop") {
            // Best-effort: prevent the assistant from responding to the control message itself
            try {
              await client.session.abort({ path: { id: sessionId } });
            } catch {}
            await startLoop(sessionId, parsed.maxLoops, parsed.prompt);
            return;
          }
        }

        return;
      }

      if (type === "session.idle") {
        const st = loops.get(sessionId);
        if (!st) return;

        st.waitingForIdle = false;

        if (st.sent >= st.maxLoops) {
          await stopLoop(sessionId, "Loop finished (max iterations reached).");
          return;
        }

        void maybeSendNext(sessionId);
        return;
      }

      // Optional: if your OpenCode emits command events, support them too
      if (type === "command.executed" || type === "tui.command.execute") {
        const p = getPayload(event);
        const cmd = String(p.command ?? p.name ?? "");
        const args = Array.isArray(p.args) ? p.args.map(String).join(" ") : "";

        if (cmd === "loop") {
          const fake = `/loop ${args}`.trim();
          const parsed = parseLoopCommandText(fake);
          if (parsed && parsed.kind === "loop") {
            await startLoop(sessionId, parsed.maxLoops, parsed.prompt);
          }
        } else if (cmd === "loopstop") {
          await stopLoop(sessionId, "Loop stopped.");
        }
      }
    },
  };
};

`​​​​​​​​​​​​​​​​​​​​​​​​​​​​

tiagoefreitas avatar Dec 13 '25 12:12 tiagoefreitas