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