opencode-queue 0.4.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 +6 -0
  2. package/index.ts +134 -128
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,9 +8,11 @@ This plugin adds a real `/queue` slash command that keeps the current run focuse
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
14
16
  - Registers `/queue` as a real OpenCode slash command
15
17
  - Shows the current queue with `/queue list`
16
18
  - Clears the current queue with `/queue clear`
@@ -37,6 +39,7 @@ While the agent is busy:
37
39
  /queue continue after the current task finishes
38
40
  /queue /review
39
41
  /queue /commit
42
+ /queue !systemctl suspend
40
43
  /queue list
41
44
  /queue clear
42
45
  ```
@@ -46,6 +49,7 @@ When the session is idle:
46
49
  ```text
47
50
  /queue hello
48
51
  /queue /review
52
+ /queue !date
49
53
  /queue
50
54
  ```
51
55
 
@@ -56,8 +60,10 @@ Queued items stay hidden while the current run is still working, then replay aut
56
60
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
57
61
  - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
58
62
  - Idle `/queue /command` immediately runs the nested command.
63
+ - Idle `/queue !command` immediately runs the shell command as a shell tool block.
59
64
  - `/queue` and `/queue list` show the in-memory queue for the current session.
60
65
  - `/queue clear` drops all currently queued items for the current session.
66
+ - Shell commands do not support attached files.
61
67
 
62
68
  ## License
63
69
 
package/index.ts CHANGED
@@ -1,29 +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]*))?$/
13
6
  const HANDLED = "__QUEUE_HANDLED__"
14
7
 
15
8
  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[] }
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 }
18
25
 
19
26
  const label = (body: string, files: number) => {
20
27
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
21
28
  return text.length > 72 ? `${text.slice(0, 69)}...` : text
22
29
  }
23
30
 
24
- const parseCommand = (body: string) => {
25
- const match = body.trim().match(COMMAND)
26
- 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 }
27
48
  }
28
49
 
29
50
  export const QueuePlugin: Plugin = async ({ client }) => {
@@ -35,89 +56,88 @@ export const QueuePlugin: Plugin = async ({ client }) => {
35
56
  const toast = (message: string, variant: "info" | "error", duration = 2500) =>
36
57
  client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
37
58
 
38
- const hide = (id: string, text: TextPart) => {
39
- hidden.add(id)
40
- text.text = ""
41
- text.synthetic = true
42
- 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)
43
62
  }
44
63
 
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])
64
+ const hide = (id: string, part: TextPart) => {
65
+ hidden.add(id)
66
+ Object.assign(part, { text: "", synthetic: true, ignored: true })
49
67
  }
50
68
 
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
- }
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"
56
75
 
57
- const clear = (sid: string) => {
58
76
  const count = queue.get(sid)?.length ?? 0
59
77
  queue.delete(sid)
60
78
  return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
61
79
  }
62
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
+
63
104
  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
68
- }
105
+ if (item.kind === "shell") return shell(sid, item.shell, item.info)
69
106
 
70
- await client.session.prompt({
107
+ if (item.kind === "command") {
108
+ await visible(sid, item.text, item.info, item.files)
109
+ await client.session.command({
71
110
  path: { id: sid },
72
111
  body: {
73
112
  agent: item.info.agent,
74
- model: item.info.model,
75
- parts: item.parts.map((part) => ({ ...part, id: undefined })),
76
- },
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,
77
118
  })
78
119
  return
79
120
  }
80
121
 
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
- })
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")
101
129
  }
102
130
 
103
131
  const flush = async (sid: string) => {
104
- if (flushing.has(sid)) return
105
-
106
132
  const list = queue.get(sid)
107
- if (!list?.length) return
133
+ if (flushing.has(sid) || !list?.length) return
108
134
 
109
135
  flushing.add(sid)
110
-
111
136
  try {
112
- while (list.length) {
113
- const item = list.shift()
114
- if (!item) break
115
- await replay(sid, item)
116
- }
137
+ while (list.length) await replay(sid, list.shift()!)
117
138
  } catch (error) {
118
- const message = error instanceof Error ? error.message : String(error)
119
139
  console.error("QueuePlugin failed to flush queued input", error)
120
- await toast(`Queue failed: ${message}`, "error")
140
+ await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
121
141
  } finally {
122
142
  if (list.length) queue.set(sid, list)
123
143
  else queue.delete(sid)
@@ -128,10 +148,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
128
148
  return {
129
149
  config: async (cfg) => {
130
150
  cfg.command ??= {}
131
- cfg.command.queue = {
132
- template: "",
133
- description: "Queue input until the session is idle",
134
- }
151
+ cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
135
152
  },
136
153
  event: async ({ event }) => {
137
154
  if (event.type !== "session.status") return
@@ -148,88 +165,77 @@ export const QueuePlugin: Plugin = async ({ client }) => {
148
165
  "command.execute.before": async (input, output) => {
149
166
  if (input.command !== "queue") return
150
167
 
168
+ const sid = input.sessionID
151
169
  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 }))
170
+ const found = files(output.parts)
171
+ const op = parse(body, found.length)
154
172
 
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)
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")
158
177
  }
159
178
 
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)
165
- return
179
+ if (!busy.has(sid)) {
180
+ if (op.kind === "shell") {
181
+ await shell(sid, op.shell, await latest(sid))
182
+ throw new Error(HANDLED)
166
183
  }
167
184
 
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)
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
184
194
  }
185
195
 
186
196
  output.parts.length = 0
187
- output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...files)
197
+ output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...found)
188
198
  },
189
- "chat.message": async ({ sessionID }, output) => {
199
+ "chat.message": async ({ sessionID: sid }, output) => {
190
200
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
191
201
  if (!text) return
192
202
 
193
203
  const body = text.text.match(QUEUE)?.[1]
194
204
  if (body === undefined) return
195
205
 
196
- const files = output.parts.filter((part): part is FilePart => part.type === "file")
197
- const trimmed = body.trim()
206
+ const found = files(output.parts)
207
+ const op = parse(body, found.length)
198
208
 
199
- if ((!trimmed || trimmed === "list" || trimmed === "clear") && !files.length) {
209
+ if (op.kind === "list" || op.kind === "clear") {
200
210
  hide(output.message.id, text)
201
- await toast(trimmed === "clear" ? clear(sessionID) : summary(sessionID), "info", 5000)
211
+ await toast(manage(sid, op), "info", 5000)
202
212
  return
203
213
  }
204
214
 
205
- if (!busy.has(sessionID)) {
206
- if (trimmed.startsWith("/")) return
207
- text.text = body
215
+ if (op.kind === "invalid") {
216
+ hide(output.message.id, text)
217
+ warn(op)
218
+ await toast(op.message, "error")
208
219
  return
209
220
  }
210
221
 
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
- })
222
+ if (!busy.has(sid)) {
223
+ if (op.kind === "command") return
224
+ text.text = body
225
+ return
226
+ }
225
227
 
226
228
  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 }
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() }
231
237
 
232
- enqueue(sessionID, item)
238
+ queue.set(sid, [...(queue.get(sid) ?? []), item])
233
239
  hide(output.message.id, text)
234
240
  await toast(`Queued: ${item.text}`, "info")
235
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.4.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",