opencode-queue 0.4.0 → 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 -1
  2. package/index.ts +127 -133
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,8 +7,10 @@ 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`
10
+ - Queues prompts with either `/queue prompt` or `prompt /queue`
11
+ - Queues slash commands with either `/queue /review` or `/review /queue`
11
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
12
14
  - Replays queued input in order once the session becomes idle
13
15
  - Replays queued commands as a visible `/command` message before executing them
14
16
  - Registers `/queue` as a real OpenCode slash command
@@ -37,6 +39,8 @@ While the agent is busy:
37
39
  /queue continue after the current task finishes
38
40
  /queue /review
39
41
  /queue /commit
42
+ continue after the current task finishes /queue
43
+ /review /queue
40
44
  /queue list
41
45
  /queue clear
42
46
  ```
@@ -46,6 +50,8 @@ When the session is idle:
46
50
  ```text
47
51
  /queue hello
48
52
  /queue /review
53
+ hello /queue
54
+ /review /queue
49
55
  /queue
50
56
  ```
51
57
 
@@ -55,9 +61,11 @@ Queued items stay hidden while the current run is still working, then replay aut
55
61
 
56
62
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
57
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.
58
65
  - Idle `/queue /command` immediately runs the nested command.
59
66
  - `/queue` and `/queue list` show the in-memory queue for the current session.
60
67
  - `/queue clear` drops all currently queued items for the current session.
68
+ - OpenCode shell mode is not supported because it uses a separate shell execution path outside normal prompt and slash-command handling.
61
69
 
62
70
  ## License
63
71
 
package/index.ts CHANGED
@@ -1,31 +1,48 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
- import type {
3
- AgentPartInput,
4
- FilePart,
5
- FilePartInput,
6
- SubtaskPartInput,
7
- TextPart,
8
- TextPartInput,
9
- } from "@opencode-ai/sdk"
2
+ import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
10
3
 
11
4
  const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
12
- const COMMAND = /^\/(\S+)(?:\s+([\s\S]*))?$/
5
+ const SUFFIX = /^([\s\S]*?)\s+\/queue\s*$/
6
+ const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
13
7
  const HANDLED = "__QUEUE_HANDLED__"
14
8
 
15
9
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
16
- type Info = { agent: string; model: { providerID: string; modelID: string } }
17
- type Item = { info: Info; text: string; parts?: InputPart[]; command?: string; arguments?: string; files?: FilePartInput[] }
10
+ type Model = { providerID: string; modelID: string }
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 }
14
+
15
+ type Item =
16
+ | { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
17
+ | { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
18
+
19
+ type Op =
20
+ | { kind: "list" }
21
+ | { kind: "clear" }
22
+ | { kind: "prompt"; text: string; body: string }
23
+ | { kind: "command"; text: string; cmd: string; args: string }
18
24
 
19
25
  const label = (body: string, files: number) => {
20
26
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
21
27
  return text.length > 72 ? `${text.slice(0, 69)}...` : text
22
28
  }
23
29
 
24
- const parseCommand = (body: string) => {
25
- const match = body.trim().match(COMMAND)
26
- return match ? { command: match[1], arguments: match[2] ?? "" } : undefined
30
+ const parse = (body: string, files = 0): Op => {
31
+ const text = body.trim()
32
+ if (!files) {
33
+ if (!text || text === "list") return { kind: "list" }
34
+ if (text === "clear") return { kind: "clear" }
35
+ }
36
+
37
+ const match = text.match(CMD)
38
+ if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
39
+ return { kind: "prompt", text: label(body, files), body }
27
40
  }
28
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
+
29
46
  export const QueuePlugin: Plugin = async ({ client }) => {
30
47
  const queue = new Map<string, Item[]>()
31
48
  const hidden = new Set<string>()
@@ -35,89 +52,78 @@ export const QueuePlugin: Plugin = async ({ client }) => {
35
52
  const toast = (message: string, variant: "info" | "error", duration = 2500) =>
36
53
  client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
37
54
 
38
- const hide = (id: string, text: TextPart) => {
39
- hidden.add(id)
40
- text.text = ""
41
- text.synthetic = true
42
- text.ignored = true
55
+ const stop = async (message: string, variant: "info" | "error" = "info", duration = 5000): Promise<never> => {
56
+ await toast(message, variant, duration)
57
+ throw new Error(HANDLED)
43
58
  }
44
59
 
45
- const enqueue = (sid: string, item: Item) => {
46
- const items = queue.get(sid)
47
- if (items) items.push(item)
48
- else queue.set(sid, [item])
60
+ const hide = (id: string, part: TextPart) => {
61
+ hidden.add(id)
62
+ Object.assign(part, { text: "", synthetic: true, ignored: true })
49
63
  }
50
64
 
51
- const summary = (sid: string) => {
52
- const items = queue.get(sid) ?? []
53
- if (!items.length) return "Queue is empty"
54
- return items.map((item, i) => `${i + 1}. ${item.text}`).join("\n")
55
- }
65
+ const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
66
+
67
+ const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
68
+ if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
56
69
 
57
- const clear = (sid: string) => {
58
70
  const count = queue.get(sid)?.length ?? 0
59
71
  queue.delete(sid)
60
72
  return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
61
73
  }
62
74
 
63
- const replay = async (sid: string, item: Item) => {
64
- if (!item.command || item.arguments === undefined) {
65
- if (!item.parts?.length) {
66
- console.warn("QueuePlugin skipped queued item without replayable content")
67
- return
75
+ const latest = async (sid: string): Promise<Info | undefined> => {
76
+ const result = await client.session.messages({ path: { id: sid }, query: { limit: 100 } }).catch((error) => {
77
+ console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
78
+ return []
79
+ })
80
+
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 }
68
85
  }
86
+ }
87
+
88
+ return undefined
89
+ }
90
+
91
+ const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
69
92
 
70
- await client.session.prompt({
93
+ const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
94
+
95
+ const replay = async (sid: string, item: Item) => {
96
+ if (item.kind === "command") {
97
+ await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
98
+ await client.session.command({
71
99
  path: { id: sid },
72
100
  body: {
73
- agent: item.info.agent,
74
- model: item.info.model,
75
- parts: item.parts.map((part) => ({ ...part, id: undefined })),
76
- },
101
+ ...opts(item.info),
102
+ model: `${item.info.model.providerID}/${item.info.model.modelID}`,
103
+ command: item.cmd,
104
+ arguments: item.args,
105
+ parts: item.files,
106
+ } as any,
77
107
  })
78
108
  return
79
109
  }
80
110
 
81
- await client.session.prompt({
82
- path: { id: sid },
83
- body: {
84
- agent: item.info.agent,
85
- model: item.info.model,
86
- noReply: true,
87
- parts: [{ type: "text", text: item.text }, ...(item.files ?? [])],
88
- },
89
- })
90
-
91
- await client.session.command({
92
- path: { id: sid },
93
- body: {
94
- agent: item.info.agent,
95
- model: `${item.info.model.providerID}/${item.info.model.modelID}`,
96
- command: item.command,
97
- arguments: item.arguments,
98
- parts: item.files,
99
- } as any,
100
- })
111
+ if (item.parts.length) {
112
+ return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
113
+ }
114
+ console.warn("QueuePlugin skipped queued item without replayable content")
101
115
  }
102
116
 
103
117
  const flush = async (sid: string) => {
104
- if (flushing.has(sid)) return
105
-
106
118
  const list = queue.get(sid)
107
- if (!list?.length) return
119
+ if (flushing.has(sid) || !list?.length) return
108
120
 
109
121
  flushing.add(sid)
110
-
111
122
  try {
112
- while (list.length) {
113
- const item = list.shift()
114
- if (!item) break
115
- await replay(sid, item)
116
- }
123
+ while (list.length) await replay(sid, list.shift()!)
117
124
  } catch (error) {
118
- const message = error instanceof Error ? error.message : String(error)
119
125
  console.error("QueuePlugin failed to flush queued input", error)
120
- await toast(`Queue failed: ${message}`, "error")
126
+ await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
121
127
  } finally {
122
128
  if (list.length) queue.set(sid, list)
123
129
  else queue.delete(sid)
@@ -128,10 +134,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
128
134
  return {
129
135
  config: async (cfg) => {
130
136
  cfg.command ??= {}
131
- cfg.command.queue = {
132
- template: "",
133
- description: "Queue input until the session is idle",
134
- }
137
+ cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
135
138
  },
136
139
  event: async ({ event }) => {
137
140
  if (event.type !== "session.status") return
@@ -146,90 +149,81 @@ export const QueuePlugin: Plugin = async ({ client }) => {
146
149
  await flush(sid)
147
150
  },
148
151
  "command.execute.before": async (input, output) => {
149
- if (input.command !== "queue") return
150
-
152
+ const sid = input.sessionID
151
153
  const body = input.arguments ?? ""
152
- const text = body.trim()
153
- const files = output.parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
154
+ const parts = files(output.parts)
154
155
 
155
- if ((!text || text === "list" || text === "clear") && !files.length) {
156
- await toast(text === "clear" ? clear(input.sessionID) : summary(input.sessionID), "info", 5000)
157
- throw new Error(HANDLED)
158
- }
156
+ if (input.command !== "queue") {
157
+ const args = trailing(body)
158
+ if (args === undefined) return
159
159
 
160
- if (!busy.has(input.sessionID)) {
161
- const cmd = parseCommand(body)
162
- if (!cmd) {
163
- output.parts.length = 0
164
- output.parts.push({ type: "text", text: body } as any, ...files)
160
+ if (!busy.has(sid)) {
161
+ for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
165
162
  return
166
163
  }
167
164
 
168
- await client.session.prompt({
169
- path: { id: input.sessionID },
170
- body: {
171
- noReply: true,
172
- parts: [{ type: "text", text }, ...files],
173
- } as any,
174
- })
175
- await client.session.command({
176
- path: { id: input.sessionID },
177
- body: {
178
- command: cmd.command,
179
- arguments: cmd.arguments,
180
- parts: files,
181
- } as any,
182
- })
183
- throw new Error(HANDLED)
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)) {
175
+ if (op.kind === "command") {
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 })
178
+ throw new Error(HANDLED)
179
+ }
180
+
181
+ output.parts.length = 0
182
+ output.parts.push({ type: "text", text: op.body } as any, ...parts)
183
+ return
184
184
  }
185
185
 
186
186
  output.parts.length = 0
187
- output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...files)
187
+ output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
188
188
  },
189
- "chat.message": async ({ sessionID }, output) => {
189
+ "chat.message": async (input, output) => {
190
+ const sid = input.sessionID
190
191
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
191
192
  if (!text) return
192
193
 
193
- const body = text.text.match(QUEUE)?.[1]
194
+ const body = queued(text.text)
194
195
  if (body === undefined) return
195
196
 
196
- const files = output.parts.filter((part): part is FilePart => part.type === "file")
197
- const trimmed = body.trim()
197
+ const parts = files(output.parts)
198
+ const op = parse(body, parts.length)
198
199
 
199
- if ((!trimmed || trimmed === "list" || trimmed === "clear") && !files.length) {
200
+ if (op.kind === "list" || op.kind === "clear") {
200
201
  hide(output.message.id, text)
201
- await toast(trimmed === "clear" ? clear(sessionID) : summary(sessionID), "info", 5000)
202
+ await toast(manage(sid, op), "info", 5000)
202
203
  return
203
204
  }
204
205
 
205
- if (!busy.has(sessionID)) {
206
- if (trimmed.startsWith("/")) return
206
+ if (!busy.has(sid)) {
207
+ if (op.kind === "command") return
207
208
  text.text = body
208
209
  return
209
210
  }
210
211
 
211
- const parts = output.parts.flatMap((part): InputPart[] => {
212
- switch (part.type) {
213
- case "text":
214
- if (part.id !== text.id) return [{ ...part }]
215
- return body ? [{ ...part, text: body }] : []
216
- case "file":
217
- case "agent":
218
- case "subtask":
219
- return [{ ...part }]
220
- default:
221
- console.warn("QueuePlugin skipped unexpected part", part.type)
222
- return []
223
- }
224
- })
225
-
226
- const info = { agent: output.message.agent, model: { ...output.message.model } }
227
- const command = parseCommand(body)
228
- const item = command
229
- ? { info, text: trimmed, files, ...command }
230
- : { info, text: label(body, files.length), parts }
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")
217
+ const inputParts = () =>
218
+ output.parts.flatMap((part): InputPart[] => {
219
+ if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
220
+ if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
221
+ console.warn("QueuePlugin skipped unexpected part", part.type)
222
+ return []
223
+ })
224
+ const item: Item = op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
231
225
 
232
- enqueue(sessionID, item)
226
+ queue.set(sid, [...(queue.get(sid) ?? []), item])
233
227
  hide(output.message.id, text)
234
228
  await toast(`Queued: ${item.text}`, "info")
235
229
  },
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.4.0",
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",