opencode-queue 0.6.3 → 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 +1 -0
  2. package/index.ts +56 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,3 +82,4 @@ The queue is in-memory and scoped to the current session.
82
82
  - This plugin registers `/queue` as a real OpenCode slash command.
83
83
  - It does not add a keyboard shortcut. OpenCode plugins cannot currently register custom TUI keybindings.
84
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> => {
@@ -137,19 +157,18 @@ export const QueuePlugin: Plugin = async ({ client }) => {
137
157
 
138
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
141
161
  const item = list.shift()
142
162
  if (!item) return
163
+ if (!list.length) queue.delete(sid)
143
164
 
144
- if (list.length) queue.set(sid, list)
145
- else queue.delete(sid)
146
-
147
- flushing.add(sid)
165
+ active.add(sid)
148
166
  void replay(sid, item).catch(async (error) => {
149
167
  console.error("QueuePlugin failed to flush queued input", error)
150
168
  await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
169
+ }).finally(() => {
170
+ active.delete(sid)
151
171
  })
152
- flushing.delete(sid)
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
@@ -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.3",
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",