opencode-queue 0.2.0 → 0.4.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 +15 -3
  2. package/index.ts +64 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
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
 
@@ -11,7 +11,9 @@ This plugin adds a `/queue ...` prefix that keeps the current run focused instea
11
11
  - Hides queued placeholders from both the UI transcript and the running agent
12
12
  - Replays queued input in order once the session becomes idle
13
13
  - Replays queued commands as a visible `/command` message before executing them
14
+ - Registers `/queue` as a real OpenCode slash command
14
15
  - Shows the current queue with `/queue list`
16
+ - Clears the current queue with `/queue clear`
15
17
 
16
18
  ## Install
17
19
 
@@ -36,6 +38,15 @@ While the agent is busy:
36
38
  /queue /review
37
39
  /queue /commit
38
40
  /queue list
41
+ /queue clear
42
+ ```
43
+
44
+ When the session is idle:
45
+
46
+ ```text
47
+ /queue hello
48
+ /queue /review
49
+ /queue
39
50
  ```
40
51
 
41
52
  Queued items stay hidden while the current run is still working, then replay automatically when the session becomes idle.
@@ -44,8 +55,9 @@ Queued items stay hidden while the current run is still working, then replay aut
44
55
 
45
56
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
46
57
  - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
47
- - Idle `/queue /command` is left alone and is not intercepted.
48
- - `/queue list` shows the in-memory queue for the current session.
58
+ - Idle `/queue /command` immediately runs the nested command.
59
+ - `/queue` and `/queue list` show the in-memory queue for the current session.
60
+ - `/queue clear` drops all currently queued items for the current session.
49
61
 
50
62
  ## License
51
63
 
package/index.ts CHANGED
@@ -10,10 +10,11 @@ import type {
10
10
 
11
11
  const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
12
12
  const COMMAND = /^\/(\S+)(?:\s+([\s\S]*))?$/
13
+ const HANDLED = "__QUEUE_HANDLED__"
13
14
 
14
15
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
15
16
  type Info = { agent: string; model: { providerID: string; modelID: string } }
16
- type Item = { info: Info; text: string; parts?: InputPart[]; command?: string; arguments?: string }
17
+ type Item = { info: Info; text: string; parts?: InputPart[]; command?: string; arguments?: string; files?: FilePartInput[] }
17
18
 
18
19
  const label = (body: string, files: number) => {
19
20
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
@@ -53,6 +54,12 @@ export const QueuePlugin: Plugin = async ({ client }) => {
53
54
  return items.map((item, i) => `${i + 1}. ${item.text}`).join("\n")
54
55
  }
55
56
 
57
+ const clear = (sid: string) => {
58
+ const count = queue.get(sid)?.length ?? 0
59
+ queue.delete(sid)
60
+ return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
61
+ }
62
+
56
63
  const replay = async (sid: string, item: Item) => {
57
64
  if (!item.command || item.arguments === undefined) {
58
65
  if (!item.parts?.length) {
@@ -77,7 +84,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
77
84
  agent: item.info.agent,
78
85
  model: item.info.model,
79
86
  noReply: true,
80
- parts: [{ type: "text", text: item.text }],
87
+ parts: [{ type: "text", text: item.text }, ...(item.files ?? [])],
81
88
  },
82
89
  })
83
90
 
@@ -88,7 +95,8 @@ export const QueuePlugin: Plugin = async ({ client }) => {
88
95
  model: `${item.info.model.providerID}/${item.info.model.modelID}`,
89
96
  command: item.command,
90
97
  arguments: item.arguments,
91
- },
98
+ parts: item.files,
99
+ } as any,
92
100
  })
93
101
  }
94
102
 
@@ -118,6 +126,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
118
126
  }
119
127
 
120
128
  return {
129
+ config: async (cfg) => {
130
+ cfg.command ??= {}
131
+ cfg.command.queue = {
132
+ template: "",
133
+ description: "Queue input until the session is idle",
134
+ }
135
+ },
121
136
  event: async ({ event }) => {
122
137
  if (event.type !== "session.status") return
123
138
 
@@ -130,6 +145,47 @@ export const QueuePlugin: Plugin = async ({ client }) => {
130
145
  busy.delete(sid)
131
146
  await flush(sid)
132
147
  },
148
+ "command.execute.before": async (input, output) => {
149
+ if (input.command !== "queue") return
150
+
151
+ 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
+
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
+ }
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)
165
+ return
166
+ }
167
+
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)
184
+ }
185
+
186
+ output.parts.length = 0
187
+ output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...files)
188
+ },
133
189
  "chat.message": async ({ sessionID }, output) => {
134
190
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
135
191
  if (!text) return
@@ -140,9 +196,9 @@ export const QueuePlugin: Plugin = async ({ client }) => {
140
196
  const files = output.parts.filter((part): part is FilePart => part.type === "file")
141
197
  const trimmed = body.trim()
142
198
 
143
- if ((!trimmed || trimmed === "list") && !files.length) {
199
+ if ((!trimmed || trimmed === "list" || trimmed === "clear") && !files.length) {
144
200
  hide(output.message.id, text)
145
- await toast(summary(sessionID), "info", 5000)
201
+ await toast(trimmed === "clear" ? clear(sessionID) : summary(sessionID), "info", 5000)
146
202
  return
147
203
  }
148
204
 
@@ -169,7 +225,9 @@ export const QueuePlugin: Plugin = async ({ client }) => {
169
225
 
170
226
  const info = { agent: output.message.agent, model: { ...output.message.model } }
171
227
  const command = parseCommand(body)
172
- const item = command ? { info, text: trimmed, ...command } : { info, text: label(body, files.length), parts }
228
+ const item = command
229
+ ? { info, text: trimmed, files, ...command }
230
+ : { info, text: label(body, files.length), parts }
173
231
 
174
232
  enqueue(sessionID, item)
175
233
  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.2.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",