Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ do this next /queue front
/queue front /review
/review /queue front

/queue /compact
/queue front /compact

/queue !ls
/queue front !pwd

Expand All @@ -57,6 +60,8 @@ do this next /queue front
| `/review /queue` | Queue a slash command using trailing syntax. |
| `/queue front /review` | Queue a slash command before existing queued entries. |
| `/review /queue front` | Queue a slash command before existing queued entries using trailing syntax. |
| `/queue /compact` | Queue OpenCode's built-in TUI `/compact` command. |
| `/queue front /compact` | Queue OpenCode's built-in TUI `/compact` command before existing queued entries. |
| `/queue !ls` | Queue an OpenCode shell block. |
| `/queue front !ls` | Queue an OpenCode shell block before existing queued entries. |
| `/queue` | Show the current queue. |
Expand Down Expand Up @@ -87,6 +92,7 @@ When the session is idle:
- `message /queue` sends `message` immediately.
- `/queue /review` runs `/review` immediately.
- `/review /queue` runs `/review` immediately.
- `/queue /compact` runs OpenCode's built-in TUI `/compact` command immediately.
- `/queue !ls` runs `ls` immediately as an OpenCode shell block.
- `/queue` and `/queue list` show the current queue.
- `/queue stop` pauses automatic replay, and `/queue start` resumes it.
Expand Down
98 changes: 64 additions & 34 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
const ITEM_NUMBER = /^[1-9]\d*$/
const HANDLED = "__QUEUE_HANDLED__"
const TUI_COMPACT = "session_compact"

type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
type Model = { providerID: string; modelID: string }
Expand All @@ -20,11 +21,13 @@ type QueueInput = { body: string; front: boolean }
type Item =
| { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] }
| { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
| { kind: "compact"; info: Info; source: string }
| { kind: "shell"; info: Info; source: string; shell: string }

type EntryOp =
| { kind: "prompt"; label: string; body: string }
| { kind: "command"; source: string; cmd: string; args: string }
| { kind: "compact"; source: string }
| { kind: "shell"; source: string; shell: string }

type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean }
Expand Down Expand Up @@ -85,7 +88,16 @@ const parse = (input: QueueInput, files = 0): Op => {
}

const match = text.match(CMD)
if (match) return { kind: "command", source: text, cmd: match[1], args: match[2] ?? "", front }
if (match) {
const cmd = match[1]
const args = match[2] ?? ""
if (cmd === "compact") {
if (args.trim()) return { kind: "invalid", message: "Queue compact does not accept arguments" }
if (files) return { kind: "invalid", message: "Queue compact does not support attachments" }
return { kind: "compact", source: text, front }
}
return { kind: "command", source: text, cmd, args, front }
}
return { kind: "prompt", label: brief(input.body, files), body: input.body, front }
}

Expand Down Expand Up @@ -114,6 +126,9 @@ const control = (op: Op): op is ControlOp => {
return false
}
}
const shouldQueue = (state?: State) => Boolean(state && (state.activity.kind !== "idle" || state.stopped))
const shouldDeclinePlan = (state?: State) => Boolean(state && (state.activity.kind === "sending" || (!state.stopped && state.items.length)))
const itemText = (item: Item) => (item.kind === "prompt" ? item.body.trim() || item.label : item.source)
const plan = (event: unknown): event is Ask => {
if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
const question = (event as Ask).properties?.questions?.[0]
Expand Down Expand Up @@ -204,30 +219,42 @@ export const QueuePlugin: Plugin = async ({ client }) => {

const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })

const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
// TUI command events target the focused session; queued replay must target the original session.
const compact = (sid: string, info: Info) =>
client.session.summarize({
path: { id: sid },
body: { providerID: info.model.providerID, modelID: info.model.modelID },
throwOnError: true,
})

const replay = async (sid: string, item: Item) => {
if (item.kind === "shell") return shell(sid, item.shell, item.info)

if (item.kind === "command") {
await client.session.command({
path: { id: sid },
body: {
...opts(item.info),
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
command: item.cmd,
arguments: item.args,
parts: item.files,
} as any,
})
return
}
switch (item.kind) {
case "shell":
return shell(sid, item.shell, item.info)
case "compact":
return compact(sid, item.info)
case "command":
return client.session.command({
path: { id: sid },
body: {
...opts(item.info),
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
command: item.cmd,
arguments: item.args,
parts: item.files,
} as any,
})
case "prompt": {
if (!item.parts.length) {
console.warn("QueuePlugin skipped queued item without replayable content")
return
}

if (item.parts.length) {
return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
const parts = item.parts.map((part) => ({ ...part, id: undefined }))
return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any })
}
}
console.warn("QueuePlugin skipped queued item without replayable content")
}

const advance = (sid: string) => {
Expand Down Expand Up @@ -289,10 +316,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {

switch (op.kind) {
case "list": {
const list =
current.items
.map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
.join("\n") || "Queue is empty"
const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
return current.stopped ? `${list}\nQueue is stopped` : list
}
case "clear":
Expand Down Expand Up @@ -323,8 +347,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
event: async ({ event }) => {
if (plan(event)) {
const sid = event.properties.sessionID
const current = sessions.get(sid)
if (!current || (current.activity.kind !== "sending" && (current.stopped || !current.items.length))) return
if (!shouldDeclinePlan(sessions.get(sid))) return
await no(event.properties.id)
await toast("Declined plan approval to continue queued work", "info")
return
Expand Down Expand Up @@ -366,9 +389,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
const queued = parseSuffix(body)
if (!queued) return

const current = sessions.get(sid)
const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
if (!shouldQueue) {
if (!shouldQueue(sessions.get(sid))) {
for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
return
}
Expand All @@ -377,19 +398,22 @@ export const QueuePlugin: Plugin = async ({ client }) => {
return
}

const current = sessions.get(sid)
const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
const op = parse(parsePrefix(body), parts.length)

if (control(op)) return stop(await manage(sid, op))
if (op.kind === "invalid") return stop(op.message, "error")

if (!shouldQueue) {
if (!shouldQueue(sessions.get(sid))) {
if (op.kind === "shell") {
await shell(sid, op.shell, await run(sid))
throw new Error(HANDLED)
}

if (op.kind === "compact") {
await client.tui.executeCommand({ body: { command: TUI_COMPACT }, throwOnError: true })
throw new Error(HANDLED)
}

if (op.kind === "command") {
await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
throw new Error(HANDLED)
Expand All @@ -412,6 +436,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
const current = state(sid)
const parts = files(output.parts)
const op = parse(request, parts.length)
const meta = input as Meta

if (control(op)) {
hide(output.message.id, text)
Expand All @@ -427,6 +452,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {

if (current.activity.kind === "idle" && !current.stopped) {
if (op.kind === "command") return
if (op.kind === "compact") {
hide(output.message.id, text)
await compact(sid, { agent: output.message.agent, model: output.message.model, variant: meta.variant, controls: meta.controls, fast: meta.fast })
return
}
if (op.kind === "shell") {
hide(output.message.id, text)
await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
Expand All @@ -436,13 +466,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
return
}

const meta = input as Meta
const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
const prior = await latest(sid)
if (prior) Object.assign(output.message, opts(prior))
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
let item: Item
if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
else {
item = {
Expand All @@ -462,7 +492,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
if (op.front) current.items.unshift(item)
else current.items.push(item)
hide(output.message.id, text)
await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
},
"experimental.chat.messages.transform": async (_, output) => {
output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))
Expand Down