opencode-queue 0.7.0 → 0.7.2
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 +11 -0
- package/index.ts +82 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,11 +25,16 @@ Restart OpenCode after installing. OpenCode installs npm plugins automatically a
|
|
|
25
25
|
```text
|
|
26
26
|
/queue continue after this task
|
|
27
27
|
continue after this task /queue
|
|
28
|
+
/queue front do this next
|
|
29
|
+
do this next /queue front
|
|
28
30
|
|
|
29
31
|
/queue /review
|
|
30
32
|
/review /queue
|
|
33
|
+
/queue front /review
|
|
34
|
+
/review /queue front
|
|
31
35
|
|
|
32
36
|
/queue !ls
|
|
37
|
+
/queue front !pwd
|
|
33
38
|
|
|
34
39
|
/queue list
|
|
35
40
|
/queue flush
|
|
@@ -44,9 +49,14 @@ continue after this task /queue
|
|
|
44
49
|
| --- | --- |
|
|
45
50
|
| `/queue message` | Queue a normal prompt. |
|
|
46
51
|
| `message /queue` | Queue a normal prompt using trailing syntax. |
|
|
52
|
+
| `/queue front message` | Queue a normal prompt before existing queued entries. |
|
|
53
|
+
| `message /queue front` | Queue a normal prompt before existing queued entries using trailing syntax. |
|
|
47
54
|
| `/queue /review` | Queue a slash command. |
|
|
48
55
|
| `/review /queue` | Queue a slash command using trailing syntax. |
|
|
56
|
+
| `/queue front /review` | Queue a slash command before existing queued entries. |
|
|
57
|
+
| `/review /queue front` | Queue a slash command before existing queued entries using trailing syntax. |
|
|
49
58
|
| `/queue !ls` | Queue an OpenCode shell block. |
|
|
59
|
+
| `/queue front !ls` | Queue an OpenCode shell block before existing queued entries. |
|
|
50
60
|
| `/queue` | Show the current queue. |
|
|
51
61
|
| `/queue list` | Show the current queue. |
|
|
52
62
|
| `/queue flush` | Send all queued entries immediately. |
|
|
@@ -61,6 +71,7 @@ When the session is busy:
|
|
|
61
71
|
- Queued entries are hidden from the transcript and from the running agent.
|
|
62
72
|
- The current agent run keeps using its original agent, model, and thinking variant.
|
|
63
73
|
- Queued entries replay in order after the session becomes idle.
|
|
74
|
+
- `/queue front ...` puts an entry before the existing queued entries.
|
|
64
75
|
- Only one queued entry is sent per idle transition, so queued work runs one item at a time.
|
|
65
76
|
- `/queue flush` sends all queued entries immediately, even before the session is idle.
|
|
66
77
|
|
package/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
|
|
|
2
2
|
import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
|
|
3
3
|
|
|
4
4
|
const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
|
|
5
|
-
const SUFFIX = /^([\s\S]*?)\s+\/queue
|
|
5
|
+
const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
|
|
6
6
|
const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
|
|
7
7
|
const ITEM_NUMBER = /^[1-9]\d*$/
|
|
8
8
|
const HANDLED = "__QUEUE_HANDLED__"
|
|
@@ -15,57 +15,78 @@ type Info = { agent: string; model: Model } & Meta
|
|
|
15
15
|
type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
|
|
16
16
|
type Ask = { type: string; properties: { id: string; sessionID: string; questions: { question: string; header: string }[] } }
|
|
17
17
|
type Post = (input: { url: string; path?: Record<string, string>; body?: unknown; headers?: Record<string, string> }) => Promise<{ response?: Response; error?: unknown } | undefined>
|
|
18
|
+
type QueueInput = { body: string; front: boolean }
|
|
18
19
|
|
|
19
20
|
type Item =
|
|
20
|
-
| { kind: "prompt"; info: Info;
|
|
21
|
-
| { kind: "command"; info: Info;
|
|
22
|
-
| { kind: "shell"; info: Info;
|
|
21
|
+
| { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] }
|
|
22
|
+
| { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
|
|
23
|
+
| { kind: "shell"; info: Info; source: string; shell: string }
|
|
24
|
+
|
|
25
|
+
type EntryOp =
|
|
26
|
+
| { kind: "prompt"; label: string; body: string }
|
|
27
|
+
| { kind: "command"; source: string; cmd: string; args: string }
|
|
28
|
+
| { kind: "shell"; source: string; shell: string }
|
|
23
29
|
|
|
24
30
|
type Op =
|
|
25
31
|
| { kind: "list" }
|
|
26
32
|
| { kind: "clear"; indices: number[] }
|
|
27
33
|
| { kind: "flush" }
|
|
28
|
-
| { kind: "invalid";
|
|
29
|
-
|
|
|
30
|
-
| { kind: "command"; text: string; cmd: string; args: string }
|
|
31
|
-
| { kind: "shell"; text: string; shell: string }
|
|
34
|
+
| { kind: "invalid"; message: string }
|
|
35
|
+
| (EntryOp & { front: boolean })
|
|
32
36
|
|
|
33
37
|
type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
|
|
34
38
|
|
|
35
|
-
const
|
|
39
|
+
const brief = (body: string, files: number) => {
|
|
36
40
|
const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
|
|
37
41
|
return text.length > 72 ? `${text.slice(0, 69)}...` : text
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
44
|
+
const parsePrefix = (body: string): QueueInput => {
|
|
45
|
+
const match = body.trim().match(/^front(?:\s+([\s\S]*))?$/)
|
|
46
|
+
return match ? { body: match[1] ?? "", front: true } : { body, front: false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parse = (input: QueueInput, files = 0): Op => {
|
|
50
|
+
const text = input.body.trim()
|
|
51
|
+
const front = input.front
|
|
52
|
+
if (!front && !files) {
|
|
43
53
|
if (!text || text === "list") return { kind: "list" }
|
|
44
54
|
if (text === "flush") return { kind: "flush" }
|
|
45
55
|
const clear = text.match(/^clear(?:\s+([\s\S]+))?$/)
|
|
46
56
|
if (clear) {
|
|
47
57
|
const values = clear[1]?.trim().split(/\s+/) ?? []
|
|
48
58
|
const indices = values.map(Number)
|
|
49
|
-
if (values.some((value) => !ITEM_NUMBER.test(value)) || indices.some((index) => !Number.isSafeInteger(index))) return { kind: "invalid",
|
|
59
|
+
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
60
|
return { kind: "clear", indices }
|
|
51
61
|
}
|
|
52
62
|
}
|
|
63
|
+
if (front && !text && !files) return { kind: "invalid", message: "Queue front input is empty" }
|
|
53
64
|
|
|
54
65
|
if (text.startsWith("!")) {
|
|
55
66
|
const shell = text.slice(1).trim()
|
|
56
|
-
if (!shell) return { kind: "invalid",
|
|
57
|
-
if (files) return { kind: "invalid",
|
|
58
|
-
return { kind: "shell", text, shell }
|
|
67
|
+
if (!shell) return { kind: "invalid", message: "Queue shell command is empty" }
|
|
68
|
+
if (files) return { kind: "invalid", message: "Queued shell commands do not support attachments" }
|
|
69
|
+
return { kind: "shell", source: text, shell, front }
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
const match = text.match(CMD)
|
|
62
|
-
if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
|
|
63
|
-
return { kind: "prompt",
|
|
73
|
+
if (match) return { kind: "command", source: text, cmd: match[1], args: match[2] ?? "", front }
|
|
74
|
+
return { kind: "prompt", label: brief(input.body, files), body: input.body, front }
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
77
|
+
const parseSuffix = (text: string): QueueInput | undefined => {
|
|
78
|
+
const trimmed = text.trim()
|
|
79
|
+
if (trimmed === "/queue") return { body: "", front: false }
|
|
80
|
+
if (trimmed === "/queue front") return { body: "", front: true }
|
|
81
|
+
|
|
82
|
+
const match = text.match(SUFFIX)
|
|
83
|
+
return match ? { body: match[1], front: match[2] === "front" } : undefined
|
|
84
|
+
}
|
|
85
|
+
const stripSuffix = (text: string) => parseSuffix(text)?.body ?? text
|
|
86
|
+
const parseInput = (text: string): QueueInput | undefined => {
|
|
87
|
+
const prefix = text.match(QUEUE)
|
|
88
|
+
return prefix ? parsePrefix(prefix[1] ?? "") : parseSuffix(text)
|
|
89
|
+
}
|
|
69
90
|
const control = (op: Op): op is ControlOp => op.kind === "list" || op.kind === "clear" || op.kind === "flush"
|
|
70
91
|
const plan = (event: unknown): event is Ask => {
|
|
71
92
|
if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
|
|
@@ -121,6 +142,17 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
121
142
|
if (items.length) queue.set(sid, [...items, ...(queue.get(sid) ?? [])])
|
|
122
143
|
}
|
|
123
144
|
|
|
145
|
+
const enqueue = (sid: string, item: Item, front: boolean) => {
|
|
146
|
+
const list = queue.get(sid)
|
|
147
|
+
if (!list) {
|
|
148
|
+
queue.set(sid, [item])
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (front) list.unshift(item)
|
|
153
|
+
else list.push(item)
|
|
154
|
+
}
|
|
155
|
+
|
|
124
156
|
const clear = (sid: string, indices: number[]) => {
|
|
125
157
|
const list = queue.get(sid)
|
|
126
158
|
if (!list?.length) return "Queue is empty"
|
|
@@ -216,7 +248,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
const manage = async (sid: string, op: ControlOp) => {
|
|
219
|
-
if (op.kind === "list")
|
|
251
|
+
if (op.kind === "list") {
|
|
252
|
+
return (queue.get(sid) ?? [])
|
|
253
|
+
.map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
|
|
254
|
+
.join("\n") || "Queue is empty"
|
|
255
|
+
}
|
|
220
256
|
if (op.kind === "clear") return clear(sid, op.indices)
|
|
221
257
|
|
|
222
258
|
const result = await flush(sid)
|
|
@@ -257,22 +293,23 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
257
293
|
const parts = files(output.parts)
|
|
258
294
|
|
|
259
295
|
if (input.command !== "queue") {
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
296
|
+
const queued = parseSuffix(body)
|
|
297
|
+
if (!queued) return
|
|
262
298
|
|
|
263
299
|
if (!busy.has(sid)) {
|
|
264
|
-
for (const part of output.parts) if (part.type === "text") part.text =
|
|
300
|
+
for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
|
|
265
301
|
return
|
|
266
302
|
}
|
|
267
303
|
|
|
268
|
-
output.parts.splice(0, output.parts.length, { type: "text", text: `/queue /${input.command}${
|
|
304
|
+
output.parts.splice(0, output.parts.length, { type: "text", text: `/queue${queued.front ? " front" : ""} /${input.command}${queued.body.trim() ? ` ${queued.body.trim()}` : ""}` } as any, ...parts)
|
|
269
305
|
return
|
|
270
306
|
}
|
|
271
307
|
|
|
272
|
-
const
|
|
308
|
+
const queued = parsePrefix(body)
|
|
309
|
+
const op = parse(queued, parts.length)
|
|
273
310
|
|
|
274
311
|
if (control(op)) return stop(await manage(sid, op))
|
|
275
|
-
if (op.kind === "invalid") return stop(op.
|
|
312
|
+
if (op.kind === "invalid") return stop(op.message, "error")
|
|
276
313
|
|
|
277
314
|
if (!busy.has(sid)) {
|
|
278
315
|
if (op.kind === "shell") {
|
|
@@ -296,11 +333,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
296
333
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
297
334
|
if (!text) return
|
|
298
335
|
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
336
|
+
const request = parseInput(text.text)
|
|
337
|
+
if (!request) return
|
|
301
338
|
|
|
302
339
|
const parts = files(output.parts)
|
|
303
|
-
const op = parse(
|
|
340
|
+
const op = parse(request, parts.length)
|
|
304
341
|
|
|
305
342
|
if (control(op)) {
|
|
306
343
|
hide(output.message.id, text)
|
|
@@ -310,7 +347,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
310
347
|
|
|
311
348
|
if (op.kind === "invalid") {
|
|
312
349
|
hide(output.message.id, text)
|
|
313
|
-
await toast(op.
|
|
350
|
+
await toast(op.message, "error", 5000)
|
|
314
351
|
return
|
|
315
352
|
}
|
|
316
353
|
|
|
@@ -321,7 +358,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
321
358
|
await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
|
|
322
359
|
return
|
|
323
360
|
}
|
|
324
|
-
text.text = body
|
|
361
|
+
text.text = request.body
|
|
325
362
|
return
|
|
326
363
|
}
|
|
327
364
|
|
|
@@ -330,23 +367,27 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
330
367
|
const prior = await latest(sid)
|
|
331
368
|
if (prior) Object.assign(output.message, opts(prior))
|
|
332
369
|
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
370
|
+
let item: Item
|
|
371
|
+
if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
|
|
372
|
+
else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
|
|
373
|
+
else {
|
|
374
|
+
item = {
|
|
375
|
+
kind: "prompt",
|
|
338
376
|
info,
|
|
377
|
+
label: op.label,
|
|
378
|
+
body: op.body,
|
|
339
379
|
parts: output.parts.flatMap((part): InputPart[] => {
|
|
340
|
-
if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
|
|
380
|
+
if (part.type === "text") return part.id === text.id ? (request.body ? [{ ...part, text: request.body }] : []) : [{ ...part }]
|
|
341
381
|
if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
|
|
342
382
|
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
343
383
|
return []
|
|
344
384
|
}),
|
|
345
385
|
}
|
|
386
|
+
}
|
|
346
387
|
|
|
347
|
-
|
|
388
|
+
enqueue(sid, item, op.front)
|
|
348
389
|
hide(output.message.id, text)
|
|
349
|
-
await toast(
|
|
390
|
+
await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
|
|
350
391
|
},
|
|
351
392
|
"experimental.chat.messages.transform": async (_, output) => {
|
|
352
393
|
output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))
|
package/package.json
CHANGED