opencode-queue 0.7.1 → 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 +67 -30
  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,20 +15,24 @@ 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
21
  | { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] }
21
22
  | { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
22
23
  | { kind: "shell"; info: Info; source: string; shell: string }
23
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 }
29
+
24
30
  type Op =
25
31
  | { kind: "list" }
26
32
  | { kind: "clear"; indices: number[] }
27
33
  | { kind: "flush" }
28
34
  | { 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 }
35
+ | (EntryOp & { front: boolean })
32
36
 
33
37
  type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
34
38
 
@@ -37,9 +41,15 @@ const brief = (body: string, files: number) => {
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]+))?$/)
@@ -50,22 +60,33 @@ const parse = (body: string, files = 0): Op => {
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
67
  if (!shell) return { kind: "invalid", message: "Queue shell command is empty" }
57
68
  if (files) return { kind: "invalid", message: "Queued shell commands do not support attachments" }
58
- return { kind: "shell", source: text, shell }
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", source: text, cmd: match[1], args: match[2] ?? "" }
63
- return { kind: "prompt", label: brief(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"
@@ -261,19 +293,20 @@ export const QueuePlugin: Plugin = async ({ client }) => {
261
293
  const parts = files(output.parts)
262
294
 
263
295
  if (input.command !== "queue") {
264
- const args = trailing(body)
265
- if (args === undefined) return
296
+ const queued = parseSuffix(body)
297
+ if (!queued) return
266
298
 
267
299
  if (!busy.has(sid)) {
268
- 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)
269
301
  return
270
302
  }
271
303
 
272
- 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)
273
305
  return
274
306
  }
275
307
 
276
- const op = parse(body, parts.length)
308
+ const queued = parsePrefix(body)
309
+ const op = parse(queued, parts.length)
277
310
 
278
311
  if (control(op)) return stop(await manage(sid, op))
279
312
  if (op.kind === "invalid") return stop(op.message, "error")
@@ -300,11 +333,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
300
333
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
301
334
  if (!text) return
302
335
 
303
- const body = queued(text.text)
304
- if (body === undefined) return
336
+ const request = parseInput(text.text)
337
+ if (!request) return
305
338
 
306
339
  const parts = files(output.parts)
307
- const op = parse(body, parts.length)
340
+ const op = parse(request, parts.length)
308
341
 
309
342
  if (control(op)) {
310
343
  hide(output.message.id, text)
@@ -325,7 +358,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
325
358
  await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
326
359
  return
327
360
  }
328
- text.text = body
361
+ text.text = request.body
329
362
  return
330
363
  }
331
364
 
@@ -334,23 +367,27 @@ export const QueuePlugin: Plugin = async ({ client }) => {
334
367
  const prior = await latest(sid)
335
368
  if (prior) Object.assign(output.message, opts(prior))
336
369
  else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
337
- const item: Item =
338
- op.kind === "shell" ? { ...op, info } :
339
- op.kind === "command" ? { ...op, info, files: parts } :
340
- {
341
- ...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",
342
376
  info,
377
+ label: op.label,
378
+ body: op.body,
343
379
  parts: output.parts.flatMap((part): InputPart[] => {
344
- 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 }]
345
381
  if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
346
382
  console.warn("QueuePlugin skipped unexpected part", part.type)
347
383
  return []
348
384
  }),
349
385
  }
386
+ }
350
387
 
351
- queue.set(sid, [...(queue.get(sid) ?? []), item])
388
+ enqueue(sid, item, op.front)
352
389
  hide(output.message.id, text)
353
- await toast(`Queued: ${item.kind === "prompt" ? item.label : item.source}`, "info")
390
+ await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
354
391
  },
355
392
  "experimental.chat.messages.transform": async (_, output) => {
356
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.1",
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",