Repository navigation
[FEATURE]: Delayed queue feature #5408
Description
Activity
- addeddiscussionUsed for feature requests, proposals, ideas, etc. Open discussionUsed for feature requests, proposals, ideas, etc. Open discussion
on Dec 11, 2025 github-actions commented
on Dec 11, 2025 on Dec 11, 2025 – with GitHub ActionsContributorMore actionsThis issue might be a duplicate of existing issues. Please check:
- [FEATURE]: Graceful handling of queued messages after session interrupt #5333: [FEATURE]: Graceful handling of queued messages after session interrupt - addresses message queueing and task interruption handling
- Chained prompts executing together instead of one after the other #3098: Chained prompts executing together instead of one after the other - discusses prompt/message execution sequencing
- [FEATURE]: Add ability to unqueue messages #4821: [FEATURE]: Add ability to unqueue messages - related queue management feature request
Feel free to ignore if none of these address your specific case.
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."); } } }, }; };`
Reacted by mathe00 and faiz@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.
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.
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.
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?
Reacted by Kian McKennaIt 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.
Reacted by Art Shendrik, Javohir Ismatullayev, David Torbeck, Jacob Daitzman, Michael Bøcker-Larsen, paw-share-club, Dyllon Gunawardhana, Savina Weerasooriya, Sherif Abdel-Naby and rpdeshaiesReacted by Art Shendrik and Jacob DaitzmanCould 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.
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.
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.
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.
Reacted by Art Shendrik, Octane, paw-share-club and maggie44Codex 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 immediateAlt + Enter→ queue at end of agent loop
Reacted by Art Shendrik, Junjie, Octane, Andy Yao, Benoit de Chezelles, David Torbeck, Nick Breaton, Jan-Henrik Damaschke, Emanuel André Medina Araujo, Sherif Abdel-Naby and 1 moreCodex 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 loopThis would be awesome if it is added to opencode, same as in the Codex App.
Reacted by mathe00, Michael Bøcker-Larsen, 陈杨文 and Ty ZesAny updates on this?
Reacted by mathe00, Amir Sharif, Web3 Enjoyer, domenicoCappello, distbit, Kian McKenna and Xynonnerswould be v useful. i am missing not having it after switching from codex
Reacted by Xynonners and Ewal11I'm definitely in support of an enter to regularly queue the message, and alt + enter for the delayed queue.
Or something of the sorts.
Reacted by distbit, mathe00, Xynonners, maggie44, wanton7 and Jan-Henrik DamaschkePi also uses ALT+Enter to push messages into delayed queue. https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent
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
Reacted by mathe00, hayatosc, mex7xx and Dmitrii-Software-TestingWould love to see codex style queuing
- removeddiscussionUsed for feature requests, proposals, ideas, etc. Open discussionUsed for feature requests, proposals, ideas, etc. Open discussion
on May 3, 2026 Codex/VSCode style queuing would be great
Dmitrii-Software-Testing commented
on Aug 3, 2026 More actionsAgree, very important feature!
Reacted by Turcan VladimirA 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.
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.
Feature 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)?