opencode-queue 0.6.6 → 0.7.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 +12 -1
  2. package/index.ts +98 -38
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -32,7 +32,10 @@ continue after this task /queue
32
32
  /queue !ls
33
33
 
34
34
  /queue list
35
+ /queue flush
35
36
  /queue clear
37
+ /queue clear 1
38
+ /queue clear 2 3
36
39
  ```
37
40
 
38
41
  ## Syntax
@@ -46,7 +49,10 @@ continue after this task /queue
46
49
  | `/queue !ls` | Queue an OpenCode shell block. |
47
50
  | `/queue` | Show the current queue. |
48
51
  | `/queue list` | Show the current queue. |
52
+ | `/queue flush` | Send all queued entries immediately. |
49
53
  | `/queue clear` | Clear the current queue. |
54
+ | `/queue clear 1` | Clear item 1 from the current queue. |
55
+ | `/queue clear 2 3` | Clear items 2 and 3 from the current queue. |
50
56
 
51
57
  ## Behavior
52
58
 
@@ -56,6 +62,7 @@ When the session is busy:
56
62
  - The current agent run keeps using its original agent, model, and thinking variant.
57
63
  - Queued entries replay in order after the session becomes idle.
58
64
  - Only one queued entry is sent per idle transition, so queued work runs one item at a time.
65
+ - `/queue flush` sends all queued entries immediately, even before the session is idle.
59
66
 
60
67
  When the session is idle:
61
68
 
@@ -65,14 +72,18 @@ When the session is idle:
65
72
  - `/review /queue` runs `/review` immediately.
66
73
  - `/queue !ls` runs `ls` immediately as an OpenCode shell block.
67
74
  - `/queue` and `/queue list` show the current queue.
68
- - `/queue clear` clears the current queue.
75
+ - `/queue flush` sends all queued entries immediately.
76
+ - `/queue clear` clears the current queue, and `/queue clear 1` clears a specific queued item.
69
77
 
70
78
  ## Queue Management
71
79
 
72
80
  ```text
73
81
  /queue
74
82
  /queue list
83
+ /queue flush
75
84
  /queue clear
85
+ /queue clear 1
86
+ /queue clear 2 3
76
87
  ```
77
88
 
78
89
  The queue is in-memory and scoped to the current session.
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPar
4
4
  const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
5
5
  const SUFFIX = /^([\s\S]*?)\s+\/queue\s*$/
6
6
  const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
7
+ const ITEM_NUMBER = /^[1-9]\d*$/
7
8
  const HANDLED = "__QUEUE_HANDLED__"
8
9
 
9
10
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
@@ -16,19 +17,22 @@ type Ask = { type: string; properties: { id: string; sessionID: string; question
16
17
  type Post = (input: { url: string; path?: Record<string, string>; body?: unknown; headers?: Record<string, string> }) => Promise<{ response?: Response; error?: unknown } | undefined>
17
18
 
18
19
  type Item =
19
- | { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
20
- | { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
21
- | { kind: "shell"; info: Info; text: string; shell: string }
20
+ | { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] }
21
+ | { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
22
+ | { kind: "shell"; info: Info; source: string; shell: string }
22
23
 
23
24
  type Op =
24
25
  | { kind: "list" }
25
- | { kind: "clear" }
26
- | { kind: "invalid"; text: string }
27
- | { kind: "prompt"; text: string; body: string }
28
- | { kind: "command"; text: string; cmd: string; args: string }
29
- | { kind: "shell"; text: string; shell: string }
26
+ | { kind: "clear"; indices: number[] }
27
+ | { kind: "flush" }
28
+ | { kind: "invalid"; message: string }
29
+ | { kind: "prompt"; label: string; body: string }
30
+ | { kind: "command"; source: string; cmd: string; args: string }
31
+ | { kind: "shell"; source: string; shell: string }
30
32
 
31
- const label = (body: string, files: number) => {
33
+ type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
34
+
35
+ const brief = (body: string, files: number) => {
32
36
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
33
37
  return text.length > 72 ? `${text.slice(0, 69)}...` : text
34
38
  }
@@ -37,24 +41,32 @@ const parse = (body: string, files = 0): Op => {
37
41
  const text = body.trim()
38
42
  if (!files) {
39
43
  if (!text || text === "list") return { kind: "list" }
40
- if (text === "clear") return { kind: "clear" }
44
+ if (text === "flush") return { kind: "flush" }
45
+ const clear = text.match(/^clear(?:\s+([\s\S]+))?$/)
46
+ if (clear) {
47
+ const values = clear[1]?.trim().split(/\s+/) ?? []
48
+ const indices = values.map(Number)
49
+ if (values.some((value) => !ITEM_NUMBER.test(value)) || indices.some((index) => !Number.isSafeInteger(index))) return { kind: "invalid", message: "Queue clear expects one or more positive item numbers" }
50
+ return { kind: "clear", indices }
51
+ }
41
52
  }
42
53
 
43
54
  if (text.startsWith("!")) {
44
55
  const shell = text.slice(1).trim()
45
- if (!shell) return { kind: "invalid", text: "Queue shell command is empty" }
46
- if (files) return { kind: "invalid", text: "Queued shell commands do not support attachments" }
47
- return { kind: "shell", text, shell }
56
+ if (!shell) return { kind: "invalid", message: "Queue shell command is empty" }
57
+ if (files) return { kind: "invalid", message: "Queued shell commands do not support attachments" }
58
+ return { kind: "shell", source: text, shell }
48
59
  }
49
60
 
50
61
  const match = text.match(CMD)
51
- if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
52
- return { kind: "prompt", text: label(body, files), body }
62
+ if (match) return { kind: "command", source: text, cmd: match[1], args: match[2] ?? "" }
63
+ return { kind: "prompt", label: brief(body, files), body }
53
64
  }
54
65
 
55
66
  const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
56
67
  const strip = (text: string) => trailing(text) ?? text
57
68
  const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
69
+ const control = (op: Op): op is ControlOp => op.kind === "list" || op.kind === "clear" || op.kind === "flush"
58
70
  const plan = (event: unknown): event is Ask => {
59
71
  if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
60
72
  const question = (event as Ask).properties?.questions?.[0]
@@ -96,12 +108,36 @@ export const QueuePlugin: Plugin = async ({ client }) => {
96
108
 
97
109
  const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
98
110
 
99
- const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
100
- if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
111
+ const take = (sid: string, count = Infinity) => {
112
+ const list = queue.get(sid)
113
+ if (!list?.length) return []
101
114
 
102
- const count = queue.get(sid)?.length ?? 0
103
- queue.delete(sid)
104
- return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
115
+ const items = list.splice(0, count)
116
+ if (!list.length) queue.delete(sid)
117
+ return items
118
+ }
119
+
120
+ const requeue = (sid: string, items: Item[]) => {
121
+ if (items.length) queue.set(sid, [...items, ...(queue.get(sid) ?? [])])
122
+ }
123
+
124
+ const clear = (sid: string, indices: number[]) => {
125
+ const list = queue.get(sid)
126
+ if (!list?.length) return "Queue is empty"
127
+
128
+ if (!indices.length) {
129
+ const count = list.length
130
+ queue.delete(sid)
131
+ return `Cleared ${count} queued item${count === 1 ? "" : "s"}`
132
+ }
133
+
134
+ const targets = [...new Set(indices)].sort((a, b) => a - b)
135
+ const missing = targets.filter((index) => index > list.length)
136
+ if (missing.length) return `Queue item${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "does" : "do"} not exist`
137
+
138
+ for (const index of targets.toReversed()) list.splice(index - 1, 1)
139
+ if (!list.length) queue.delete(sid)
140
+ return `Cleared queued item${targets.length === 1 ? "" : "s"} ${targets.join(", ")}`
105
141
  }
106
142
 
107
143
  const latest = async (sid: string): Promise<Info | undefined> => {
@@ -154,20 +190,44 @@ export const QueuePlugin: Plugin = async ({ client }) => {
154
190
  console.warn("QueuePlugin skipped queued item without replayable content")
155
191
  }
156
192
 
157
- const flush = (sid: string) => {
158
- const list = queue.get(sid)
159
- if (!list?.length) return
160
- const item = list.shift()
161
- if (!item) return
162
- if (!list.length) queue.delete(sid)
193
+ const flush = async (sid: string, count = Infinity) => {
194
+ const items = take(sid, count)
195
+ if (!items.length) return { sent: 0, failed: 0 }
163
196
 
164
197
  active.add(sid)
165
- void replay(sid, item).catch(async (error) => {
166
- console.error("QueuePlugin failed to flush queued input", error)
167
- await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
168
- }).finally(() => {
198
+ let failed = 0
199
+ const retry: Item[] = []
200
+ try {
201
+ for (const item of items) {
202
+ try {
203
+ await replay(sid, item)
204
+ } catch (error) {
205
+ failed++
206
+ retry.push(item)
207
+ console.error("QueuePlugin failed to flush queued input", error)
208
+ await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
209
+ }
210
+ }
211
+ } finally {
212
+ requeue(sid, retry)
169
213
  active.delete(sid)
170
- })
214
+ }
215
+ return { sent: items.length - failed, failed }
216
+ }
217
+
218
+ const manage = async (sid: string, op: ControlOp) => {
219
+ if (op.kind === "list") {
220
+ return (queue.get(sid) ?? [])
221
+ .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
222
+ .join("\n") || "Queue is empty"
223
+ }
224
+ if (op.kind === "clear") return clear(sid, op.indices)
225
+
226
+ const result = await flush(sid)
227
+ if (!result.sent && !result.failed) return "Queue is empty"
228
+
229
+ const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
230
+ return result.failed ? `${message}; ${result.failed} failed` : message
171
231
  }
172
232
 
173
233
  return {
@@ -193,7 +253,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
193
253
  }
194
254
 
195
255
  busy.delete(sid)
196
- flush(sid)
256
+ void flush(sid, 1)
197
257
  },
198
258
  "command.execute.before": async (input, output) => {
199
259
  const sid = input.sessionID
@@ -215,8 +275,8 @@ export const QueuePlugin: Plugin = async ({ client }) => {
215
275
 
216
276
  const op = parse(body, parts.length)
217
277
 
218
- if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
219
- if (op.kind === "invalid") return stop(op.text, "error")
278
+ if (control(op)) return stop(await manage(sid, op))
279
+ if (op.kind === "invalid") return stop(op.message, "error")
220
280
 
221
281
  if (!busy.has(sid)) {
222
282
  if (op.kind === "shell") {
@@ -246,15 +306,15 @@ export const QueuePlugin: Plugin = async ({ client }) => {
246
306
  const parts = files(output.parts)
247
307
  const op = parse(body, parts.length)
248
308
 
249
- if (op.kind === "list" || op.kind === "clear") {
309
+ if (control(op)) {
250
310
  hide(output.message.id, text)
251
- await toast(manage(sid, op), "info", 5000)
311
+ await toast(await manage(sid, op), "info", 5000)
252
312
  return
253
313
  }
254
314
 
255
315
  if (op.kind === "invalid") {
256
316
  hide(output.message.id, text)
257
- await toast(op.text, "error", 5000)
317
+ await toast(op.message, "error", 5000)
258
318
  return
259
319
  }
260
320
 
@@ -290,7 +350,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
290
350
 
291
351
  queue.set(sid, [...(queue.get(sid) ?? []), item])
292
352
  hide(output.message.id, text)
293
- await toast(`Queued: ${item.text}`, "info")
353
+ await toast(`Queued: ${item.kind === "prompt" ? item.label : item.source}`, "info")
294
354
  },
295
355
  "experimental.chat.messages.transform": async (_, output) => {
296
356
  output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))
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.6.6",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",
@@ -28,6 +28,9 @@
28
28
  "exports": {
29
29
  ".": "./index.ts"
30
30
  },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
31
34
  "publishConfig": {
32
35
  "access": "public"
33
36
  },