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.
- package/README.md +12 -1
- package/index.ts +98 -38
- 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
|
|
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;
|
|
20
|
-
| { kind: "command"; info: Info;
|
|
21
|
-
| { kind: "shell"; info: Info;
|
|
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: "
|
|
27
|
-
| { kind: "
|
|
28
|
-
| { kind: "
|
|
29
|
-
| { kind: "
|
|
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
|
-
|
|
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 === "
|
|
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",
|
|
46
|
-
if (files) return { kind: "invalid",
|
|
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",
|
|
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
|
|
100
|
-
|
|
111
|
+
const take = (sid: string, count = Infinity) => {
|
|
112
|
+
const list = queue.get(sid)
|
|
113
|
+
if (!list?.length) return []
|
|
101
114
|
|
|
102
|
-
const
|
|
103
|
-
queue.delete(sid)
|
|
104
|
-
return
|
|
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
|
|
159
|
-
if (!
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
219
|
-
if (op.kind === "invalid") return stop(op.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
},
|