opencode-queue 0.5.1 → 0.6.0

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 +9 -7
  2. package/index.ts +54 -66
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,12 +7,12 @@ 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
12
  - Hides queued placeholders from both the UI transcript and the running agent
13
+ - Preserves the selected agent, model, and thinking variant for queued input
13
14
  - Replays queued input in order once the session becomes idle
14
15
  - 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
16
  - Registers `/queue` as a real OpenCode slash command
17
17
  - Shows the current queue with `/queue list`
18
18
  - Clears the current queue with `/queue clear`
@@ -39,7 +39,8 @@ While the agent is busy:
39
39
  /queue continue after the current task finishes
40
40
  /queue /review
41
41
  /queue /commit
42
- /queue !systemctl suspend
42
+ continue after the current task finishes /queue
43
+ /review /queue
43
44
  /queue list
44
45
  /queue clear
45
46
  ```
@@ -49,7 +50,8 @@ When the session is idle:
49
50
  ```text
50
51
  /queue hello
51
52
  /queue /review
52
- /queue !date
53
+ hello /queue
54
+ /review /queue
53
55
  /queue
54
56
  ```
55
57
 
@@ -59,11 +61,11 @@ Queued items stay hidden while the current run is still working, then replay aut
59
61
 
60
62
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
61
63
  - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
64
+ - Idle `some text /queue` and `/command /queue` run immediately with the trailing `/queue` removed.
62
65
  - Idle `/queue /command` immediately runs the nested command.
63
- - Idle `/queue !command` immediately runs the shell command as a shell tool block.
64
66
  - `/queue` and `/queue list` show the in-memory queue for the current session.
65
67
  - `/queue clear` drops all currently queued items for the current session.
66
- - Shell commands do not support attached files.
68
+ - OpenCode shell mode is not supported because it uses a separate shell execution path outside normal prompt and slash-command handling.
67
69
 
68
70
  ## License
69
71
 
package/index.ts CHANGED
@@ -2,26 +2,25 @@ 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 Run = { agent: string; model?: Model }
11
+ type Meta = { variant?: string; controls?: string[]; fast?: boolean }
12
+ type Info = { agent: string; model: Model } & Meta
13
+ type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
12
14
 
13
15
  type Item =
14
16
  | { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
15
17
  | { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
16
- | { kind: "shell"; info: Info; text: string; shell: string }
17
18
 
18
19
  type Op =
19
20
  | { kind: "list" }
20
21
  | { kind: "clear" }
21
- | { kind: "invalid"; message: string; warn?: string }
22
22
  | { kind: "prompt"; text: string; body: string }
23
23
  | { kind: "command"; text: string; cmd: string; args: string }
24
- | { kind: "shell"; text: string; shell: string }
25
24
 
26
25
  const label = (body: string, files: number) => {
27
26
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
@@ -35,18 +34,15 @@ const parse = (body: string, files = 0): Op => {
35
34
  if (text === "clear") return { kind: "clear" }
36
35
  }
37
36
 
38
- if (text.startsWith("!")) {
39
- 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" }
42
- return { kind: "shell", text, shell }
43
- }
44
-
45
37
  const match = text.match(CMD)
46
38
  if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
47
39
  return { kind: "prompt", text: label(body, files), body }
48
40
  }
49
41
 
42
+ const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
43
+ const strip = (text: string) => trailing(text) ?? text
44
+ const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
45
+
50
46
  export const QueuePlugin: Plugin = async ({ client }) => {
51
47
  const queue = new Map<string, Item[]>()
52
48
  const hidden = new Set<string>()
@@ -66,9 +62,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
66
62
  Object.assign(part, { text: "", synthetic: true, ignored: true })
67
63
  }
68
64
 
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) }] : []))
65
+ const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
72
66
 
73
67
  const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
74
68
  if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
@@ -78,38 +72,33 @@ export const QueuePlugin: Plugin = async ({ client }) => {
78
72
  return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
79
73
  }
80
74
 
81
- const latest = async (sid: string): Promise<Run> => {
75
+ const latest = async (sid: string): Promise<Info | undefined> => {
82
76
  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)
77
+ console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
84
78
  return []
85
79
  })
86
80
 
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 } }
81
+ for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]) {
82
+ 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 }
83
+ if (msg.info.role === "assistant" && (msg.info.agent || msg.info.mode) && msg.info.providerID && msg.info.modelID) {
84
+ 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 }
85
+ }
90
86
  }
91
87
 
92
- console.warn("QueuePlugin shell replay fell back to the build agent because the session has no message context")
93
- return { agent: "build" }
88
+ return undefined
94
89
  }
95
90
 
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
- })
91
+ const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
101
92
 
102
- const shell = (sid: string, command: string, run: Run) => client.session.shell({ path: { id: sid }, body: { ...run, command } })
93
+ const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
103
94
 
104
95
  const replay = async (sid: string, item: Item) => {
105
- if (item.kind === "shell") return shell(sid, item.shell, item.info)
106
-
107
96
  if (item.kind === "command") {
108
- await visible(sid, item.text, item.info, item.files)
97
+ await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
109
98
  await client.session.command({
110
99
  path: { id: sid },
111
100
  body: {
112
- agent: item.info.agent,
101
+ ...opts(item.info),
113
102
  model: `${item.info.model.providerID}/${item.info.model.modelID}`,
114
103
  command: item.cmd,
115
104
  arguments: item.args,
@@ -120,10 +109,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
120
109
  }
121
110
 
122
111
  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
- })
112
+ return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
127
113
  }
128
114
  console.warn("QueuePlugin skipped queued item without replayable content")
129
115
  }
@@ -163,48 +149,53 @@ export const QueuePlugin: Plugin = async ({ client }) => {
163
149
  await flush(sid)
164
150
  },
165
151
  "command.execute.before": async (input, output) => {
166
- if (input.command !== "queue") return
167
-
168
152
  const sid = input.sessionID
169
153
  const body = input.arguments ?? ""
170
- const found = files(output.parts)
171
- const op = parse(body, found.length)
154
+ const parts = files(output.parts)
172
155
 
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
- }
156
+ if (input.command !== "queue") {
157
+ const args = trailing(body)
158
+ if (args === undefined) return
178
159
 
179
- if (!busy.has(sid)) {
180
- if (op.kind === "shell") {
181
- await shell(sid, op.shell, await latest(sid))
182
- throw new Error(HANDLED)
160
+ if (!busy.has(sid)) {
161
+ for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
162
+ return
183
163
  }
184
164
 
165
+ output.parts.length = 0
166
+ output.parts.push({ type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
167
+ return
168
+ }
169
+
170
+ const op = parse(body, parts.length)
171
+
172
+ if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
173
+
174
+ if (!busy.has(sid)) {
185
175
  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 })
176
+ await client.session.prompt({ path: { id: sid }, body: { noReply: true, parts: [{ type: "text", text: op.text }, ...parts] } })
177
+ await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
188
178
  throw new Error(HANDLED)
189
179
  }
190
180
 
191
181
  output.parts.length = 0
192
- output.parts.push({ type: "text", text: op.body } as any, ...found)
182
+ output.parts.push({ type: "text", text: op.body } as any, ...parts)
193
183
  return
194
184
  }
195
185
 
196
186
  output.parts.length = 0
197
- output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...found)
187
+ output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
198
188
  },
199
- "chat.message": async ({ sessionID: sid }, output) => {
189
+ "chat.message": async (input, output) => {
190
+ const sid = input.sessionID
200
191
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
201
192
  if (!text) return
202
193
 
203
- const body = text.text.match(QUEUE)?.[1]
194
+ const body = queued(text.text)
204
195
  if (body === undefined) return
205
196
 
206
- const found = files(output.parts)
207
- const op = parse(body, found.length)
197
+ const parts = files(output.parts)
198
+ const op = parse(body, parts.length)
208
199
 
209
200
  if (op.kind === "list" || op.kind === "clear") {
210
201
  hide(output.message.id, text)
@@ -212,20 +203,17 @@ export const QueuePlugin: Plugin = async ({ client }) => {
212
203
  return
213
204
  }
214
205
 
215
- if (op.kind === "invalid") {
216
- hide(output.message.id, text)
217
- warn(op)
218
- await toast(op.message, "error")
219
- return
220
- }
221
-
222
206
  if (!busy.has(sid)) {
223
207
  if (op.kind === "command") return
224
208
  text.text = body
225
209
  return
226
210
  }
227
211
 
228
- const info = { agent: output.message.agent, model: { ...output.message.model } }
212
+ const meta = input as Meta
213
+ const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
214
+ const prior = await latest(sid)
215
+ if (prior) Object.assign(output.message, opts(prior))
216
+ else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
229
217
  const inputParts = () =>
230
218
  output.parts.flatMap((part): InputPart[] => {
231
219
  if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
@@ -233,7 +221,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
233
221
  console.warn("QueuePlugin skipped unexpected part", part.type)
234
222
  return []
235
223
  })
236
- const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files: found } : { ...op, info, parts: inputParts() }
224
+ const item: Item = op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
237
225
 
238
226
  queue.set(sid, [...(queue.get(sid) ?? []), item])
239
227
  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.0",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",