opencode-queue 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -6
  2. package/index.ts +71 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,12 +7,13 @@ This plugin adds a real `/queue` slash command that keeps the current run focuse
7
7
  ## What it does
8
8
 
9
9
  - Queues normal prompts entered while a session is busy
10
- - Queues slash commands like `/queue /review` and `/queue /commit`
11
- - Queues shell commands like `/queue !systemctl suspend`
10
+ - Queues prompts with either `/queue prompt` or `prompt /queue`
11
+ - Queues slash commands with either `/queue /review` or `/review /queue`
12
+ - Queues shell commands with `/queue !ls`
12
13
  - Hides queued placeholders from both the UI transcript and the running agent
14
+ - Preserves the selected agent, model, and thinking variant for queued input
13
15
  - Replays queued input in order once the session becomes idle
14
16
  - Replays queued commands as a visible `/command` message before executing them
15
- - Replays queued shell commands as shell tool blocks without adding a literal `!command` user message
16
17
  - Registers `/queue` as a real OpenCode slash command
17
18
  - Shows the current queue with `/queue list`
18
19
  - Clears the current queue with `/queue clear`
@@ -39,7 +40,9 @@ While the agent is busy:
39
40
  /queue continue after the current task finishes
40
41
  /queue /review
41
42
  /queue /commit
42
- /queue !systemctl suspend
43
+ /queue !ls
44
+ continue after the current task finishes /queue
45
+ /review /queue
43
46
  /queue list
44
47
  /queue clear
45
48
  ```
@@ -50,6 +53,8 @@ When the session is idle:
50
53
  /queue hello
51
54
  /queue /review
52
55
  /queue !date
56
+ hello /queue
57
+ /review /queue
53
58
  /queue
54
59
  ```
55
60
 
@@ -59,11 +64,12 @@ Queued items stay hidden while the current run is still working, then replay aut
59
64
 
60
65
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
61
66
  - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
67
+ - Idle `some text /queue` and `/command /queue` run immediately with the trailing `/queue` removed.
62
68
  - Idle `/queue /command` immediately runs the nested command.
63
- - Idle `/queue !command` immediately runs the shell command as a shell tool block.
69
+ - Idle `/queue !command` immediately runs the shell command as an OpenCode shell block.
64
70
  - `/queue` and `/queue list` show the in-memory queue for the current session.
65
71
  - `/queue clear` drops all currently queued items for the current session.
66
- - Shell commands do not support attached files.
72
+ - Native shell-mode suffixes like `!command /queue` are not supported because OpenCode handles leading `!` before plugin command hooks run.
67
73
 
68
74
  ## License
69
75
 
package/index.ts CHANGED
@@ -2,13 +2,16 @@ import type { Plugin } from "@opencode-ai/plugin"
2
2
  import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
3
3
 
4
4
  const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
5
+ const SUFFIX = /^([\s\S]*?)\s+\/queue\s*$/
5
6
  const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
6
7
  const HANDLED = "__QUEUE_HANDLED__"
7
8
 
8
9
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
9
10
  type Model = { providerID: string; modelID: string }
10
- type Info = { agent: string; model: Model }
11
+ type Meta = { variant?: string; controls?: string[]; fast?: boolean }
11
12
  type Run = { agent: string; model?: Model }
13
+ type Info = { agent: string; model: Model } & Meta
14
+ type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
12
15
 
13
16
  type Item =
14
17
  | { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
@@ -18,7 +21,7 @@ type Item =
18
21
  type Op =
19
22
  | { kind: "list" }
20
23
  | { kind: "clear" }
21
- | { kind: "invalid"; message: string; warn?: string }
24
+ | { kind: "invalid"; text: string }
22
25
  | { kind: "prompt"; text: string; body: string }
23
26
  | { kind: "command"; text: string; cmd: string; args: string }
24
27
  | { kind: "shell"; text: string; shell: string }
@@ -37,8 +40,8 @@ const parse = (body: string, files = 0): Op => {
37
40
 
38
41
  if (text.startsWith("!")) {
39
42
  const shell = text.slice(1).trim()
40
- if (!shell) return { kind: "invalid", message: "Queue shell command is empty" }
41
- if (files) return { kind: "invalid", message: "Queued shell commands do not support attachments", warn: "QueuePlugin skipped shell command attachments" }
43
+ if (!shell) return { kind: "invalid", text: "Queue shell command is empty" }
44
+ if (files) return { kind: "invalid", text: "Queued shell commands do not support attachments" }
42
45
  return { kind: "shell", text, shell }
43
46
  }
44
47
 
@@ -47,6 +50,10 @@ const parse = (body: string, files = 0): Op => {
47
50
  return { kind: "prompt", text: label(body, files), body }
48
51
  }
49
52
 
53
+ const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
54
+ const strip = (text: string) => trailing(text) ?? text
55
+ const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
56
+
50
57
  export const QueuePlugin: Plugin = async ({ client }) => {
51
58
  const queue = new Map<string, Item[]>()
52
59
  const hidden = new Set<string>()
@@ -66,9 +73,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
66
73
  Object.assign(part, { text: "", synthetic: true, ignored: true })
67
74
  }
68
75
 
69
- const warn = (op: Extract<Op, { kind: "invalid" }>) => op.warn && console.warn(op.warn)
70
-
71
- const files = (parts: { type: string }[]) => parts.flatMap((part) => (part.type === "file" ? [{ ...(part as FilePart) }] : []))
76
+ const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
72
77
 
73
78
  const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
74
79
  if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
@@ -78,38 +83,43 @@ export const QueuePlugin: Plugin = async ({ client }) => {
78
83
  return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
79
84
  }
80
85
 
81
- const latest = async (sid: string): Promise<Run> => {
86
+ const latest = async (sid: string): Promise<Info | undefined> => {
82
87
  const result = await client.session.messages({ path: { id: sid }, query: { limit: 100 } }).catch((error) => {
83
- console.warn("QueuePlugin could not inspect session messages for shell replay", error)
88
+ console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
84
89
  return []
85
90
  })
86
91
 
87
- for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse()) {
88
- if (msg.info.role === "user") return { agent: msg.info.agent, model: msg.info.model }
89
- if (msg.info.role === "assistant") return { agent: msg.info.mode, model: { providerID: msg.info.providerID, modelID: msg.info.modelID } }
92
+ for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]) {
93
+ if (msg.info.role === "user" && msg.info.agent && msg.info.model) return { agent: msg.info.agent, model: msg.info.model, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
94
+ if (msg.info.role === "assistant" && (msg.info.agent || msg.info.mode) && msg.info.providerID && msg.info.modelID) {
95
+ return { agent: msg.info.agent ?? msg.info.mode!, model: { providerID: msg.info.providerID, modelID: msg.info.modelID }, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
96
+ }
90
97
  }
91
98
 
99
+ return undefined
100
+ }
101
+
102
+ const run = async (sid: string): Promise<Run> => {
103
+ const info = await latest(sid)
104
+ if (info) return info
92
105
  console.warn("QueuePlugin shell replay fell back to the build agent because the session has no message context")
93
106
  return { agent: "build" }
94
107
  }
95
108
 
96
- const visible = (sid: string, text: string, info?: Info, parts: FilePartInput[] = []) =>
97
- client.session.prompt({
98
- path: { id: sid },
99
- body: { agent: info?.agent, model: info?.model, noReply: true, parts: [{ type: "text", text }, ...parts] },
100
- })
109
+ const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
101
110
 
102
- const shell = (sid: string, command: string, run: Run) => client.session.shell({ path: { id: sid }, body: { ...run, command } })
111
+ const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
112
+ const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
103
113
 
104
114
  const replay = async (sid: string, item: Item) => {
105
115
  if (item.kind === "shell") return shell(sid, item.shell, item.info)
106
116
 
107
117
  if (item.kind === "command") {
108
- await visible(sid, item.text, item.info, item.files)
118
+ await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
109
119
  await client.session.command({
110
120
  path: { id: sid },
111
121
  body: {
112
- agent: item.info.agent,
122
+ ...opts(item.info),
113
123
  model: `${item.info.model.providerID}/${item.info.model.modelID}`,
114
124
  command: item.cmd,
115
125
  arguments: item.args,
@@ -120,10 +130,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
120
130
  }
121
131
 
122
132
  if (item.parts.length) {
123
- return client.session.prompt({
124
- path: { id: sid },
125
- body: { agent: item.info.agent, model: item.info.model, parts: item.parts.map((part) => ({ ...part, id: undefined })) },
126
- })
133
+ return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
127
134
  }
128
135
  console.warn("QueuePlugin skipped queued item without replayable content")
129
136
  }
@@ -163,48 +170,59 @@ export const QueuePlugin: Plugin = async ({ client }) => {
163
170
  await flush(sid)
164
171
  },
165
172
  "command.execute.before": async (input, output) => {
166
- if (input.command !== "queue") return
167
-
168
173
  const sid = input.sessionID
169
174
  const body = input.arguments ?? ""
170
- const found = files(output.parts)
171
- const op = parse(body, found.length)
175
+ const parts = files(output.parts)
172
176
 
173
- if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
174
- if (op.kind === "invalid") {
175
- warn(op)
176
- return stop(op.message, "error")
177
+ if (input.command !== "queue") {
178
+ const args = trailing(body)
179
+ if (args === undefined) return
180
+
181
+ if (!busy.has(sid)) {
182
+ for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
183
+ return
184
+ }
185
+
186
+ output.parts.length = 0
187
+ output.parts.push({ type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
188
+ return
177
189
  }
178
190
 
191
+ const op = parse(body, parts.length)
192
+
193
+ if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
194
+ if (op.kind === "invalid") return stop(op.text, "error")
195
+
179
196
  if (!busy.has(sid)) {
180
197
  if (op.kind === "shell") {
181
- await shell(sid, op.shell, await latest(sid))
198
+ await shell(sid, op.shell, await run(sid))
182
199
  throw new Error(HANDLED)
183
200
  }
184
201
 
185
202
  if (op.kind === "command") {
186
- await visible(sid, op.text, undefined, found)
187
- await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts: found } as any })
203
+ await client.session.prompt({ path: { id: sid }, body: { noReply: true, parts: [{ type: "text", text: op.text }, ...parts] } })
204
+ await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
188
205
  throw new Error(HANDLED)
189
206
  }
190
207
 
191
208
  output.parts.length = 0
192
- output.parts.push({ type: "text", text: op.body } as any, ...found)
209
+ output.parts.push({ type: "text", text: op.body } as any, ...parts)
193
210
  return
194
211
  }
195
212
 
196
213
  output.parts.length = 0
197
- output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...found)
214
+ output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
198
215
  },
199
- "chat.message": async ({ sessionID: sid }, output) => {
216
+ "chat.message": async (input, output) => {
217
+ const sid = input.sessionID
200
218
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
201
219
  if (!text) return
202
220
 
203
- const body = text.text.match(QUEUE)?.[1]
221
+ const body = queued(text.text)
204
222
  if (body === undefined) return
205
223
 
206
- const found = files(output.parts)
207
- const op = parse(body, found.length)
224
+ const parts = files(output.parts)
225
+ const op = parse(body, parts.length)
208
226
 
209
227
  if (op.kind === "list" || op.kind === "clear") {
210
228
  hide(output.message.id, text)
@@ -214,18 +232,26 @@ export const QueuePlugin: Plugin = async ({ client }) => {
214
232
 
215
233
  if (op.kind === "invalid") {
216
234
  hide(output.message.id, text)
217
- warn(op)
218
- await toast(op.message, "error")
235
+ await toast(op.text, "error", 5000)
219
236
  return
220
237
  }
221
238
 
222
239
  if (!busy.has(sid)) {
223
240
  if (op.kind === "command") return
241
+ if (op.kind === "shell") {
242
+ hide(output.message.id, text)
243
+ await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
244
+ return
245
+ }
224
246
  text.text = body
225
247
  return
226
248
  }
227
249
 
228
- const info = { agent: output.message.agent, model: { ...output.message.model } }
250
+ const meta = input as Meta
251
+ const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
252
+ const prior = await latest(sid)
253
+ if (prior) Object.assign(output.message, opts(prior))
254
+ else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
229
255
  const inputParts = () =>
230
256
  output.parts.flatMap((part): InputPart[] => {
231
257
  if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
@@ -233,7 +259,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
233
259
  console.warn("QueuePlugin skipped unexpected part", part.type)
234
260
  return []
235
261
  })
236
- const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files: found } : { ...op, info, parts: inputParts() }
262
+ const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
237
263
 
238
264
  queue.set(sid, [...(queue.get(sid) ?? []), item])
239
265
  hide(output.message.id, text)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-queue",
4
- "version": "0.5.1",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",