[FEATURE]: Delayed queue feature
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)?
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.
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.");
}
}
},
};
};
`