opencode-queue 0.6.5 → 0.7.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 +12 -1
  2. package/index.ts +78 -24
  3. package/package.json +7 -4
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
@@ -22,12 +23,15 @@ type Item =
22
23
 
23
24
  type Op =
24
25
  | { kind: "list" }
25
- | { kind: "clear" }
26
+ | { kind: "clear"; indices: number[] }
27
+ | { kind: "flush" }
26
28
  | { kind: "invalid"; text: string }
27
29
  | { kind: "prompt"; text: string; body: string }
28
30
  | { kind: "command"; text: string; cmd: string; args: string }
29
31
  | { kind: "shell"; text: string; shell: string }
30
32
 
33
+ type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
34
+
31
35
  const label = (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
@@ -37,7 +41,14 @@ 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", text: "Queue clear expects one or more positive item numbers" }
50
+ return { kind: "clear", indices }
51
+ }
41
52
  }
42
53
 
43
54
  if (text.startsWith("!")) {
@@ -55,6 +66,7 @@ const parse = (body: string, files = 0): Op => {
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 []
114
+
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
+ }
101
133
 
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"
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> => {
@@ -135,7 +171,6 @@ export const QueuePlugin: Plugin = async ({ client }) => {
135
171
  if (item.kind === "shell") return shell(sid, item.shell, item.info)
136
172
 
137
173
  if (item.kind === "command") {
138
- await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
139
174
  await client.session.command({
140
175
  path: { id: sid },
141
176
  body: {
@@ -155,20 +190,40 @@ export const QueuePlugin: Plugin = async ({ client }) => {
155
190
  console.warn("QueuePlugin skipped queued item without replayable content")
156
191
  }
157
192
 
158
- const flush = (sid: string) => {
159
- const list = queue.get(sid)
160
- if (!list?.length) return
161
- const item = list.shift()
162
- if (!item) return
163
- 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 }
164
196
 
165
197
  active.add(sid)
166
- void replay(sid, item).catch(async (error) => {
167
- console.error("QueuePlugin failed to flush queued input", error)
168
- await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
169
- }).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)
170
213
  active.delete(sid)
171
- })
214
+ }
215
+ return { sent: items.length - failed, failed }
216
+ }
217
+
218
+ const manage = async (sid: string, op: ControlOp) => {
219
+ if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
220
+ if (op.kind === "clear") return clear(sid, op.indices)
221
+
222
+ const result = await flush(sid)
223
+ if (!result.sent && !result.failed) return "Queue is empty"
224
+
225
+ const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
226
+ return result.failed ? `${message}; ${result.failed} failed` : message
172
227
  }
173
228
 
174
229
  return {
@@ -194,7 +249,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
194
249
  }
195
250
 
196
251
  busy.delete(sid)
197
- flush(sid)
252
+ void flush(sid, 1)
198
253
  },
199
254
  "command.execute.before": async (input, output) => {
200
255
  const sid = input.sessionID
@@ -216,7 +271,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
216
271
 
217
272
  const op = parse(body, parts.length)
218
273
 
219
- if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
274
+ if (control(op)) return stop(await manage(sid, op))
220
275
  if (op.kind === "invalid") return stop(op.text, "error")
221
276
 
222
277
  if (!busy.has(sid)) {
@@ -226,7 +281,6 @@ export const QueuePlugin: Plugin = async ({ client }) => {
226
281
  }
227
282
 
228
283
  if (op.kind === "command") {
229
- await client.session.prompt({ path: { id: sid }, body: { noReply: true, parts: [{ type: "text", text: op.text }, ...parts] } })
230
284
  await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
231
285
  throw new Error(HANDLED)
232
286
  }
@@ -248,9 +302,9 @@ export const QueuePlugin: Plugin = async ({ client }) => {
248
302
  const parts = files(output.parts)
249
303
  const op = parse(body, parts.length)
250
304
 
251
- if (op.kind === "list" || op.kind === "clear") {
305
+ if (control(op)) {
252
306
  hide(output.message.id, text)
253
- await toast(manage(sid, op), "info", 5000)
307
+ await toast(await manage(sid, op), "info", 5000)
254
308
  return
255
309
  }
256
310
 
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.5",
4
+ "version": "0.7.0",
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
  },
@@ -35,9 +38,9 @@
35
38
  "typecheck": "tsc --noEmit"
36
39
  },
37
40
  "devDependencies": {
38
- "@opencode-ai/plugin": "latest",
39
- "@opencode-ai/sdk": "latest",
40
- "typescript": "^5.9.3"
41
+ "@opencode-ai/plugin": "^1.14.37",
42
+ "@opencode-ai/sdk": "^1.14.37",
43
+ "typescript": "^6.0.3"
41
44
  },
42
45
  "overrides": {
43
46
  "uuid": "^14.0.0"