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.
Files changed (3) hide show
  1. package/README.md +11 -0
  2. package/index.ts +82 -41
  3. 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\s*$/
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; text: string; parts: InputPart[] }
21
- | { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
22
- | { kind: "shell"; info: Info; text: string; shell: string }
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"; text: string }
29
- | { kind: "prompt"; text: string; body: string }
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 label = (body: string, files: number) => {
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 parse = (body: string, files = 0): Op => {
41
- const text = body.trim()
42
- if (!files) {
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", text: "Queue clear expects one or more positive item numbers" }
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", text: "Queue shell command is empty" }
57
- if (files) return { kind: "invalid", text: "Queued shell commands do not support attachments" }
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", text: label(body, files), body }
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 trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
67
- const strip = (text: string) => trailing(text) ?? text
68
- const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
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") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
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 args = trailing(body)
261
- if (args === undefined) return
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 = strip(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}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
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 op = parse(body, parts.length)
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.text, "error")
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 body = queued(text.text)
300
- if (body === undefined) return
336
+ const request = parseInput(text.text)
337
+ if (!request) return
301
338
 
302
339
  const parts = files(output.parts)
303
- const op = parse(body, parts.length)
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.text, "error", 5000)
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
- const item: Item =
334
- op.kind === "shell" ? { ...op, info } :
335
- op.kind === "command" ? { ...op, info, files: parts } :
336
- {
337
- ...op,
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
- queue.set(sid, [...(queue.get(sid) ?? []), item])
388
+ enqueue(sid, item, op.front)
348
389
  hide(output.message.id, text)
349
- await toast(`Queued: ${item.text}`, "info")
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-queue",
4
- "version": "0.7.0",
4
+ "version": "0.7.2",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",