Skip to content

[FEATURE]: Delayed queue feature #5408

Description

@jaresty

Feature hasn't been suggested before.

  • 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)?

Activity

  1. added
    discussionUsed for feature requests, proposals, ideas, etc. Open discussion
    on Dec 11, 2025
  2. github-actions commented on Dec 11, 2025

    @github-actions
    Contributor

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

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

  3. tiagoefreitas commented on Dec 13, 2025

    @tiagoefreitas

    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.");
            }
          }
        },
      };
    };
    
    

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

  4. synergiator commented on Dec 21, 2025

    @synergiator

    @jaresty one aspect is immediate to-queue backgrounding: what would you say if the system offered not just to put your background task to the queue but persist it - one way to store task query is for example the beads (bd) plugin.

    #5895

    However, currently I don't see a way how to make opencode process such stored queue autonomously - on explicit request or just in the background as it idles. I can imagine lots of tiny non-critical tasks which could add up a lot on codebase quality for example, or allowing create independent experimental features you maybe wouldn't like to merge immediately.

  5. jaresty commented on Dec 21, 2025

    @jaresty
    Author

    Yes I think background processing on idle is what I've been looking for. For now, the workaround I've been using is to, in my prompt, specify how many times I want it to execute. That seems to work pretty well with the caveat that eventually I run out of context so some kind of automatic compaction would be really helpful then.

  6. rekram1-node commented on Dec 21, 2025

    @rekram1-node
    Collaborator

    there are people with opinions on both fronts, do you all think itd be good to offer this as a configuration option? Like we can keep default the same but do a codex like queuing instead if u have certain config set?

  7. jaresty commented on Dec 21, 2025

    @jaresty
    Author

    It would be nice if you could switch; I.e. sometimes queue for idle and other times send as soon as possible. I have found cases where sending sooner is useful too.

  8. synergiator commented on Dec 22, 2025

    @synergiator

    Could be also that actual requirement is to have an off-TUI background daemon to take care of repos to do chores of predictable complexity and with high success rate even with some reiterations (dynamic programming alike).

    The demand for unattended work goes then beyond TUI detaching to CICD integration like the Renovate Bot does.

    gitlab.com/nagyv/gitlab-opencode

  9. jaresty commented on Dec 23, 2025

    @jaresty
    Author

    Could be also that actual requirement is to have an off-TUI background daemon to take care of repos to do chores of predictable complexity and with high success rate even with some reiterations (dynamic programming alike).

    The demand for unattended work goes then beyond TUI detaching to CICD integration like the Renovate Bot does.

    gitlab.com/nagyv/gitlab-opencode

    This also seems useful, but I'm not currently prepared to dramatically change my workflow and I'm not certain if I can in my current enterprise environment. I had been quite happy with codex's job handling but I like opencode's UI better mostly so I'm mostly using this now but it is limiting what I can do because of the job handling.

  10. sepsi77 commented on Jan 7, 2026

    @sepsi77

    It would be very helpful to have this as a command, something like /queue to setup a message that gets sent when the agent finishes. The use case for this is quite different for immediately sent messages, which I use to steer the ongoing work. Queue use case would be to send a follow up task to the agent, or to ask it if it's really done or just providing a summary report of what was done.

  11. bndnsmth commented on Jan 16, 2026

    @bndnsmth
    Contributor

    Codex just pushed an experimental feature to shift default behavior to steering, or mid-turn queueing via openai/codex#9077, which aligns with opencode. However, they maintain a hotkey for tail-queuing so that both use-cases remain available (vs a either/or config). For opencode that may look like:

    • Enter → queue immediate
    • Alt + Enter → queue at end of agent loop
  12. itpropro commented on Feb 12, 2026

    @itpropro

    Codex just pushed an experimental feature to shift default behavior to steering, or mid-turn queueing via openai/codex#9077, which aligns with opencode. However, they maintain a hotkey for tail-queuing so that both use-cases remain available (vs a either/or config). For opencode that may look like:

    * `Enter` → queue immediate
    
    * `Alt + Enter` → queue at end of agent loop
    

    This would be awesome if it is added to opencode, same as in the Codex App.

  13. mathe00 commented on Feb 12, 2026

    @mathe00

    I think @itpropro's suggestion is the best. However, while waiting for it to be implemented, I'm doing what @sepsi77 says with a /queue command using a plugin.

  14. itpropro commented on Mar 24, 2026

    @itpropro

    Any updates on this?

  15. distbit0 commented on Apr 4, 2026

    @distbit0

    would be v useful. i am missing not having it after switching from codex

  16. cowboycodr commented on Apr 5, 2026

    @cowboycodr

    I'm definitely in support of an enter to regularly queue the message, and alt + enter for the delayed queue.

    Or something of the sorts.

  17. wanton7 commented on Apr 15, 2026

    @wanton7

    Pi also uses ALT+Enter to push messages into delayed queue. https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent

  18. mirsella commented on Apr 15, 2026

    @mirsella
    Contributor

    even 5.4 xhigh required multiples prompt to get a working plugin, and no one shared theirs, so here's mine https://github.com/mirsella/opencode-queue

    edit: improved the plugin a lot, there new features and its really stable and working well :) easy install and usage too

  19. Ewal11 commented on Apr 28, 2026

    @Ewal11

    Would love to see codex style queuing

  20. removed
    discussionUsed for feature requests, proposals, ideas, etc. Open discussion
    on May 3, 2026
  21. kevin91nl commented on May 8, 2026

    @kevin91nl

    Codex/VSCode style queuing would be great

  22. Dmitrii-Software-Testing commented on Aug 3, 2026

    @Dmitrii-Software-Testing

    Agree, very important feature!

  23. chaosmanage commented on Aug 22, 2026

    @chaosmanage

    A minimal fix for this is up in #44117 — it restores the General → Follow-up behavior (Queue/Steer) setting that was disabled in ae7e2eb, which un-blocks the queue machinery that still ships in the app. If you'd like this landed, a 👍 on the PR helps it survive the automated PR cleanup and get maintainer review.

  24. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    OpenCode v2 has a delayed queue next to the default steering behavior:

    • Enter steers: the message is delivered at the next step boundary while the agent is still working.
    • ctrl+x return (prompt.queue) queues: the message waits until the agent is idle and never interrupts current work.

    Queued messages are delivered one at a time. The agent finishes the first one, goes idle, and only then gets the next, so you can line up several prompts and step away. While a message is pending you can also move it between queue and steer, or delete it.

    Added in #40922; the keybind moved to command IDs in #41882.

    Closing as completed. If something doesn't work on v2, please open a new issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions