opencode-queue 0.6.2 → 0.6.4

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 +53 -44
  2. package/index.ts +62 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,26 +1,16 @@
1
1
  # opencode-queue
2
2
 
3
- Queue OpenCode input until the current agent run is actually idle.
3
+ [![npm version](https://img.shields.io/npm/v/opencode-queue?color=cb3837)](https://www.npmjs.com/package/opencode-queue)
4
+ [![CI](https://github.com/mirsella/opencode-queue/actions/workflows/ci.yml/badge.svg)](https://github.com/mirsella/opencode-queue/actions/workflows/ci.yml)
5
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-queue)](https://www.npmjs.com/package/opencode-queue)
4
6
 
5
- This plugin adds a real `/queue` slash command that keeps the current run focused instead of injecting your next message into the still-running loop.
7
+ Queue OpenCode input until the current session is idle.
6
8
 
7
- ## What it does
8
-
9
- - Queues normal prompts entered while a session is busy
10
- - Queues prompts with either `/queue prompt` or `prompt /queue`
11
- - Queues slash commands with either `/queue /review` or `/review /queue`
12
- - Queues shell commands with `/queue !ls`
13
- - Hides queued placeholders from both the UI transcript and the running agent
14
- - Preserves the selected agent, model, and thinking variant for queued input
15
- - Replays queued input in order once the session becomes idle
16
- - Replays queued commands as a visible `/command` message before executing them
17
- - Registers `/queue` as a real OpenCode slash command
18
- - Shows the current queue with `/queue list`
19
- - Clears the current queue with `/queue clear`
9
+ `opencode-queue` adds a real `/queue` slash command. It lets you type the next prompt, slash command, or shell command while an agent is still working, without interrupting the current run.
20
10
 
21
11
  ## Install
22
12
 
23
- Add it to your OpenCode plugin list:
13
+ Add the plugin to your OpenCode config:
24
14
 
25
15
  ```jsonc
26
16
  {
@@ -28,49 +18,68 @@ Add it to your OpenCode plugin list:
28
18
  }
29
19
  ```
30
20
 
31
- OpenCode installs npm plugins automatically at startup.
32
-
33
- Restart OpenCode after installing.
34
-
35
- ## Usage
21
+ Restart OpenCode after installing. OpenCode installs npm plugins automatically at startup.
36
22
 
37
- While the agent is busy:
23
+ ## Quick Examples
38
24
 
39
25
  ```text
40
- /queue continue after the current task finishes
26
+ /queue continue after this task
27
+ continue after this task /queue
28
+
41
29
  /queue /review
42
- /queue /commit
43
- /queue !ls
44
- continue after the current task finishes /queue
45
30
  /review /queue
31
+
32
+ /queue !ls
33
+
46
34
  /queue list
47
35
  /queue clear
48
36
  ```
49
37
 
38
+ ## Syntax
39
+
40
+ | Input | What it does |
41
+ | --- | --- |
42
+ | `/queue message` | Queue a normal prompt. |
43
+ | `message /queue` | Queue a normal prompt using trailing syntax. |
44
+ | `/queue /review` | Queue a slash command. |
45
+ | `/review /queue` | Queue a slash command using trailing syntax. |
46
+ | `/queue !ls` | Queue an OpenCode shell block. |
47
+ | `/queue` | Show the current queue. |
48
+ | `/queue list` | Show the current queue. |
49
+ | `/queue clear` | Clear the current queue. |
50
+
51
+ ## Behavior
52
+
53
+ When the session is busy:
54
+
55
+ - Queued entries are hidden from the transcript and from the running agent.
56
+ - The current agent run keeps using its original agent, model, and thinking variant.
57
+ - Queued entries replay in order after the session becomes idle.
58
+ - Only one queued entry is sent per idle transition, so queued work runs one item at a time.
59
+
50
60
  When the session is idle:
51
61
 
62
+ - `/queue message` sends `message` immediately.
63
+ - `message /queue` sends `message` immediately.
64
+ - `/queue /review` runs `/review` immediately.
65
+ - `/review /queue` runs `/review` immediately.
66
+ - `/queue !ls` runs `ls` immediately as an OpenCode shell block.
67
+ - `/queue` and `/queue list` show the current queue.
68
+ - `/queue clear` clears the current queue.
69
+
70
+ ## Queue Management
71
+
52
72
  ```text
53
- /queue hello
54
- /queue /review
55
- /queue !date
56
- hello /queue
57
- /review /queue
58
73
  /queue
74
+ /queue list
75
+ /queue clear
59
76
  ```
60
77
 
61
- Queued items stay hidden while the current run is still working, then replay automatically when the session becomes idle.
78
+ The queue is in-memory and scoped to the current session.
62
79
 
63
80
  ## Notes
64
81
 
65
- - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
66
- - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
67
- - Idle `some text /queue` and `/command /queue` run immediately with the trailing `/queue` removed.
68
- - Idle `/queue /command` immediately runs the nested command.
69
- - Idle `/queue !command` immediately runs the shell command as an OpenCode shell block.
70
- - `/queue` and `/queue list` show the in-memory queue for the current session.
71
- - `/queue clear` drops all currently queued items for the current session.
72
- - Native shell-mode suffixes like `!command /queue` are not supported because OpenCode handles leading `!` before plugin command hooks run.
73
-
74
- ## License
75
-
76
- MIT
82
+ - This plugin registers `/queue` as a real OpenCode slash command.
83
+ - It does not add a keyboard shortcut. OpenCode plugins cannot currently register custom TUI keybindings.
84
+ - Queued placeholders are hidden instead of deleted, then filtered out before messages are sent to the model.
85
+ - If plan mode asks to switch to the build agent while more queued work is waiting, the plugin answers `No` so the queue can continue.
package/index.ts CHANGED
@@ -12,6 +12,8 @@ type Meta = { variant?: string; controls?: string[]; fast?: boolean }
12
12
  type Run = { agent: string; model?: Model }
13
13
  type Info = { agent: string; model: Model } & Meta
14
14
  type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
15
+ type Ask = { type: string; properties: { id: string; sessionID: string; questions: { question: string; header: string }[] } }
16
+ type Post = (input: { url: string; path?: Record<string, string>; body?: unknown; headers?: Record<string, string> }) => Promise<{ response?: Response; error?: unknown } | undefined>
15
17
 
16
18
  type Item =
17
19
  | { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
@@ -53,12 +55,18 @@ const parse = (body: string, files = 0): Op => {
53
55
  const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
54
56
  const strip = (text: string) => trailing(text) ?? text
55
57
  const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
58
+ const plan = (event: unknown): event is Ask => {
59
+ if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
60
+ const question = (event as Ask).properties?.questions?.[0]
61
+ return question?.header === "Build Agent" && question.question.includes("switch to the build agent")
62
+ }
56
63
 
57
64
  export const QueuePlugin: Plugin = async ({ client }) => {
58
65
  const queue = new Map<string, Item[]>()
59
66
  const hidden = new Set<string>()
60
67
  const busy = new Set<string>()
61
- const flushing = new Set<string>()
68
+ const active = new Set<string>()
69
+ const post = (client as unknown as { _client?: { post?: Post } })._client?.post
62
70
 
63
71
  const toast = (message: string, variant: "info" | "error", duration = 2500) =>
64
72
  client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
@@ -68,6 +76,19 @@ export const QueuePlugin: Plugin = async ({ client }) => {
68
76
  throw new Error(HANDLED)
69
77
  }
70
78
 
79
+ const no = async (id: string) => {
80
+ if (!post) {
81
+ console.warn("QueuePlugin cannot answer plan prompt because the SDK client has no internal request method")
82
+ return
83
+ }
84
+
85
+ const result = await post({ url: "/question/{requestID}/reply", path: { requestID: id }, body: { answers: [["No"]] } }).catch((error) => {
86
+ console.warn("QueuePlugin failed to answer plan prompt", error)
87
+ return undefined
88
+ })
89
+ if (!result?.response?.ok) console.warn("QueuePlugin failed to answer plan prompt", result?.error ?? result?.response?.status)
90
+ }
91
+
71
92
  const hide = (id: string, part: TextPart) => {
72
93
  hidden.add(id)
73
94
  Object.assign(part, { text: "", synthetic: true, ignored: true })
@@ -89,14 +110,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
89
110
  return []
90
111
  })
91
112
 
92
- for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]) {
93
- if (msg.info.role === "user" && msg.info.agent && msg.info.model) return { agent: msg.info.agent, model: msg.info.model, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
113
+ return ([...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]).flatMap((msg): Info[] => {
114
+ if (msg.info.role === "user" && msg.info.agent && msg.info.model) return [{ agent: msg.info.agent, model: msg.info.model, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }]
94
115
  if (msg.info.role === "assistant" && (msg.info.agent || msg.info.mode) && msg.info.providerID && msg.info.modelID) {
95
- return { agent: msg.info.agent ?? msg.info.mode!, model: { providerID: msg.info.providerID, modelID: msg.info.modelID }, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
116
+ return [{ agent: msg.info.agent ?? msg.info.mode!, model: { providerID: msg.info.providerID, modelID: msg.info.modelID }, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }]
96
117
  }
97
- }
98
-
99
- return undefined
118
+ return []
119
+ })[0]
100
120
  }
101
121
 
102
122
  const run = async (sid: string): Promise<Run> => {
@@ -135,21 +155,20 @@ export const QueuePlugin: Plugin = async ({ client }) => {
135
155
  console.warn("QueuePlugin skipped queued item without replayable content")
136
156
  }
137
157
 
138
- const flush = async (sid: string) => {
158
+ const flush = (sid: string) => {
139
159
  const list = queue.get(sid)
140
- if (flushing.has(sid) || !list?.length) return
160
+ if (!list?.length) return
161
+ const item = list.shift()
162
+ if (!item) return
163
+ if (!list.length) queue.delete(sid)
141
164
 
142
- flushing.add(sid)
143
- try {
144
- await replay(sid, list.shift()!)
145
- } catch (error) {
165
+ active.add(sid)
166
+ void replay(sid, item).catch(async (error) => {
146
167
  console.error("QueuePlugin failed to flush queued input", error)
147
168
  await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
148
- } finally {
149
- if (list.length) queue.set(sid, list)
150
- else queue.delete(sid)
151
- flushing.delete(sid)
152
- }
169
+ }).finally(() => {
170
+ active.delete(sid)
171
+ })
153
172
  }
154
173
 
155
174
  return {
@@ -158,6 +177,14 @@ export const QueuePlugin: Plugin = async ({ client }) => {
158
177
  cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
159
178
  },
160
179
  event: async ({ event }) => {
180
+ if (plan(event)) {
181
+ const sid = event.properties.sessionID
182
+ if (!active.has(sid) && !queue.get(sid)?.length) return
183
+ await no(event.properties.id)
184
+ await toast("Declined plan approval to continue queued work", "info")
185
+ return
186
+ }
187
+
161
188
  if (event.type !== "session.status") return
162
189
 
163
190
  const sid = event.properties.sessionID
@@ -167,7 +194,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
167
194
  }
168
195
 
169
196
  busy.delete(sid)
170
- await flush(sid)
197
+ flush(sid)
171
198
  },
172
199
  "command.execute.before": async (input, output) => {
173
200
  const sid = input.sessionID
@@ -183,8 +210,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
183
210
  return
184
211
  }
185
212
 
186
- output.parts.length = 0
187
- output.parts.push({ type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
213
+ output.parts.splice(0, output.parts.length, { type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
188
214
  return
189
215
  }
190
216
 
@@ -205,13 +231,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
205
231
  throw new Error(HANDLED)
206
232
  }
207
233
 
208
- output.parts.length = 0
209
- output.parts.push({ type: "text", text: op.body } as any, ...parts)
234
+ output.parts.splice(0, output.parts.length, { type: "text", text: op.body } as any, ...parts)
210
235
  return
211
236
  }
212
237
 
213
- output.parts.length = 0
214
- output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
238
+ output.parts.splice(0, output.parts.length, { type: "text", text: `/queue ${body}` } as any, ...parts)
215
239
  },
216
240
  "chat.message": async (input, output) => {
217
241
  const sid = input.sessionID
@@ -252,14 +276,19 @@ export const QueuePlugin: Plugin = async ({ client }) => {
252
276
  const prior = await latest(sid)
253
277
  if (prior) Object.assign(output.message, opts(prior))
254
278
  else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
255
- const inputParts = () =>
256
- output.parts.flatMap((part): InputPart[] => {
257
- if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
258
- if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
259
- console.warn("QueuePlugin skipped unexpected part", part.type)
260
- return []
261
- })
262
- const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
279
+ const item: Item =
280
+ op.kind === "shell" ? { ...op, info } :
281
+ op.kind === "command" ? { ...op, info, files: parts } :
282
+ {
283
+ ...op,
284
+ info,
285
+ parts: output.parts.flatMap((part): InputPart[] => {
286
+ if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
287
+ if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
288
+ console.warn("QueuePlugin skipped unexpected part", part.type)
289
+ return []
290
+ }),
291
+ }
263
292
 
264
293
  queue.set(sid, [...(queue.get(sid) ?? []), item])
265
294
  hide(output.message.id, text)
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.2",
4
+ "version": "0.6.4",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",