opencode-queue 0.7.2 → 0.9.0

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 +10 -1
  2. package/index.ts +116 -62
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,8 @@ do this next /queue front
37
37
  /queue front !pwd
38
38
 
39
39
  /queue list
40
+ /queue stop
41
+ /queue start
40
42
  /queue flush
41
43
  /queue clear
42
44
  /queue clear 1
@@ -59,6 +61,8 @@ do this next /queue front
59
61
  | `/queue front !ls` | Queue an OpenCode shell block before existing queued entries. |
60
62
  | `/queue` | Show the current queue. |
61
63
  | `/queue list` | Show the current queue. |
64
+ | `/queue stop` | Pause automatic sending of queued entries. |
65
+ | `/queue start` | Resume automatic sending of queued entries. |
62
66
  | `/queue flush` | Send all queued entries immediately. |
63
67
  | `/queue clear` | Clear the current queue. |
64
68
  | `/queue clear 1` | Clear item 1 from the current queue. |
@@ -70,9 +74,11 @@ When the session is busy:
70
74
 
71
75
  - Queued entries are hidden from the transcript and from the running agent.
72
76
  - The current agent run keeps using its original agent, model, and thinking variant.
73
- - Queued entries replay in order after the session becomes idle.
77
+ - Queued entries replay in order after the session completes normally and becomes idle.
74
78
  - `/queue front ...` puts an entry before the existing queued entries.
75
79
  - Only one queued entry is sent per idle transition, so queued work runs one item at a time.
80
+ - Queued entries are kept in place after an error or abort.
81
+ - `/queue stop` pauses automatic replay without clearing queued entries, and `/queue start` resumes it.
76
82
  - `/queue flush` sends all queued entries immediately, even before the session is idle.
77
83
 
78
84
  When the session is idle:
@@ -83,6 +89,7 @@ When the session is idle:
83
89
  - `/review /queue` runs `/review` immediately.
84
90
  - `/queue !ls` runs `ls` immediately as an OpenCode shell block.
85
91
  - `/queue` and `/queue list` show the current queue.
92
+ - `/queue stop` pauses automatic replay, and `/queue start` resumes it.
86
93
  - `/queue flush` sends all queued entries immediately.
87
94
  - `/queue clear` clears the current queue, and `/queue clear 1` clears a specific queued item.
88
95
 
@@ -91,6 +98,8 @@ When the session is idle:
91
98
  ```text
92
99
  /queue
93
100
  /queue list
101
+ /queue stop
102
+ /queue start
94
103
  /queue flush
95
104
  /queue clear
96
105
  /queue clear 1
package/index.ts CHANGED
@@ -27,14 +27,18 @@ type EntryOp =
27
27
  | { kind: "command"; source: string; cmd: string; args: string }
28
28
  | { kind: "shell"; source: string; shell: string }
29
29
 
30
+ type State = { items: Item[]; busy: boolean; flushing: boolean; stopped: boolean; failed: boolean }
31
+
30
32
  type Op =
31
33
  | { kind: "list" }
32
34
  | { kind: "clear"; indices: number[] }
33
35
  | { kind: "flush" }
36
+ | { kind: "start" }
37
+ | { kind: "stop" }
34
38
  | { kind: "invalid"; message: string }
35
39
  | (EntryOp & { front: boolean })
36
40
 
37
- type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
41
+ type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" | "start" | "stop" }>
38
42
 
39
43
  const brief = (body: string, files: number) => {
40
44
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
@@ -50,8 +54,18 @@ const parse = (input: QueueInput, files = 0): Op => {
50
54
  const text = input.body.trim()
51
55
  const front = input.front
52
56
  if (!front && !files) {
53
- if (!text || text === "list") return { kind: "list" }
54
- if (text === "flush") return { kind: "flush" }
57
+ switch (text) {
58
+ case "":
59
+ case "list":
60
+ return { kind: "list" }
61
+ case "flush":
62
+ return { kind: "flush" }
63
+ case "start":
64
+ return { kind: "start" }
65
+ case "stop":
66
+ return { kind: "stop" }
67
+ }
68
+
55
69
  const clear = text.match(/^clear(?:\s+([\s\S]+))?$/)
56
70
  if (clear) {
57
71
  const values = clear[1]?.trim().split(/\s+/) ?? []
@@ -87,7 +101,18 @@ const parseInput = (text: string): QueueInput | undefined => {
87
101
  const prefix = text.match(QUEUE)
88
102
  return prefix ? parsePrefix(prefix[1] ?? "") : parseSuffix(text)
89
103
  }
90
- const control = (op: Op): op is ControlOp => op.kind === "list" || op.kind === "clear" || op.kind === "flush"
104
+ const control = (op: Op): op is ControlOp => {
105
+ switch (op.kind) {
106
+ case "list":
107
+ case "clear":
108
+ case "flush":
109
+ case "start":
110
+ case "stop":
111
+ return true
112
+ default:
113
+ return false
114
+ }
115
+ }
91
116
  const plan = (event: unknown): event is Ask => {
92
117
  if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
93
118
  const question = (event as Ask).properties?.questions?.[0]
@@ -95,12 +120,19 @@ const plan = (event: unknown): event is Ask => {
95
120
  }
96
121
 
97
122
  export const QueuePlugin: Plugin = async ({ client }) => {
98
- const queue = new Map<string, Item[]>()
123
+ const sessions = new Map<string, State>()
99
124
  const hidden = new Set<string>()
100
- const busy = new Set<string>()
101
- const active = new Set<string>()
102
125
  const post = (client as unknown as { _client?: { post?: Post } })._client?.post
103
126
 
127
+ const state = (sid: string) => {
128
+ let current = sessions.get(sid)
129
+ if (!current) {
130
+ current = { items: [], busy: false, flushing: false, stopped: false, failed: false }
131
+ sessions.set(sid, current)
132
+ }
133
+ return current
134
+ }
135
+
104
136
  const toast = (message: string, variant: "info" | "error", duration = 2500) =>
105
137
  client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
106
138
 
@@ -129,37 +161,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
129
161
 
130
162
  const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
131
163
 
132
- const take = (sid: string, count = Infinity) => {
133
- const list = queue.get(sid)
134
- if (!list?.length) return []
135
-
136
- const items = list.splice(0, count)
137
- if (!list.length) queue.delete(sid)
138
- return items
139
- }
140
-
141
- const requeue = (sid: string, items: Item[]) => {
142
- if (items.length) queue.set(sid, [...items, ...(queue.get(sid) ?? [])])
143
- }
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
-
156
- const clear = (sid: string, indices: number[]) => {
157
- const list = queue.get(sid)
158
- if (!list?.length) return "Queue is empty"
164
+ const clear = (current: State, indices: number[]) => {
165
+ const list = current.items
166
+ if (!list.length) return "Queue is empty"
159
167
 
160
168
  if (!indices.length) {
161
169
  const count = list.length
162
- queue.delete(sid)
170
+ list.splice(0)
163
171
  return `Cleared ${count} queued item${count === 1 ? "" : "s"}`
164
172
  }
165
173
 
@@ -168,7 +176,6 @@ export const QueuePlugin: Plugin = async ({ client }) => {
168
176
  if (missing.length) return `Queue item${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "does" : "do"} not exist`
169
177
 
170
178
  for (const index of targets.toReversed()) list.splice(index - 1, 1)
171
- if (!list.length) queue.delete(sid)
172
179
  return `Cleared queued item${targets.length === 1 ? "" : "s"} ${targets.join(", ")}`
173
180
  }
174
181
 
@@ -223,10 +230,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
223
230
  }
224
231
 
225
232
  const flush = async (sid: string, count = Infinity) => {
226
- const items = take(sid, count)
233
+ const current = state(sid)
234
+ const items = current.items.splice(0, count)
227
235
  if (!items.length) return { sent: 0, failed: 0 }
228
236
 
229
- active.add(sid)
237
+ current.flushing = true
230
238
  let failed = 0
231
239
  const retry: Item[] = []
232
240
  try {
@@ -241,25 +249,47 @@ export const QueuePlugin: Plugin = async ({ client }) => {
241
249
  }
242
250
  }
243
251
  } finally {
244
- requeue(sid, retry)
245
- active.delete(sid)
252
+ if (retry.length) current.items.unshift(...retry)
253
+ current.flushing = false
246
254
  }
247
255
  return { sent: items.length - failed, failed }
248
256
  }
249
257
 
258
+ const advance = (sid: string) => {
259
+ const current = state(sid)
260
+ if (!current.items.length || current.busy || current.stopped || current.flushing) return
261
+ void flush(sid, 1)
262
+ }
263
+
250
264
  const manage = async (sid: string, op: ControlOp) => {
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"
265
+ const current = state(sid)
266
+
267
+ switch (op.kind) {
268
+ case "list": {
269
+ const list =
270
+ current.items
271
+ .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
272
+ .join("\n") || "Queue is empty"
273
+ return current.stopped ? `${list}\nQueue is stopped` : list
274
+ }
275
+ case "clear":
276
+ return clear(current, op.indices)
277
+ case "stop":
278
+ current.stopped = true
279
+ return "Queue stopped"
280
+ case "start":
281
+ current.stopped = false
282
+ current.failed = false
283
+ advance(sid)
284
+ return "Queue started"
285
+ case "flush": {
286
+ const result = await flush(sid)
287
+ if (!result.sent && !result.failed) return "Queue is empty"
288
+
289
+ const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
290
+ return result.failed ? `${message}; ${result.failed} failed` : message
291
+ }
255
292
  }
256
- if (op.kind === "clear") return clear(sid, op.indices)
257
-
258
- const result = await flush(sid)
259
- if (!result.sent && !result.failed) return "Queue is empty"
260
-
261
- const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
262
- return result.failed ? `${message}; ${result.failed} failed` : message
263
293
  }
264
294
 
265
295
  return {
@@ -270,22 +300,41 @@ export const QueuePlugin: Plugin = async ({ client }) => {
270
300
  event: async ({ event }) => {
271
301
  if (plan(event)) {
272
302
  const sid = event.properties.sessionID
273
- if (!active.has(sid) && !queue.get(sid)?.length) return
303
+ const current = sessions.get(sid)
304
+ if (!current || (!current.flushing && (current.stopped || !current.items.length))) return
274
305
  await no(event.properties.id)
275
306
  await toast("Declined plan approval to continue queued work", "info")
276
307
  return
277
308
  }
278
309
 
310
+ if (event.type === "session.error") {
311
+ const sid = event.properties.sessionID
312
+ if (!sid) {
313
+ console.warn("QueuePlugin could not suppress queued replay after session.error because the event has no sessionID")
314
+ return
315
+ }
316
+ state(sid).failed = true
317
+ return
318
+ }
319
+
320
+ if (event.type === "session.idle") {
321
+ const current = state(event.properties.sessionID)
322
+ current.busy = false
323
+ if (!current.failed) advance(event.properties.sessionID)
324
+ current.failed = false
325
+ return
326
+ }
327
+
279
328
  if (event.type !== "session.status") return
280
329
 
281
- const sid = event.properties.sessionID
330
+ const current = state(event.properties.sessionID)
282
331
  if (event.properties.status.type !== "idle") {
283
- busy.add(sid)
332
+ current.busy = true
333
+ current.failed = false
284
334
  return
285
335
  }
286
336
 
287
- busy.delete(sid)
288
- void flush(sid, 1)
337
+ current.busy = false
289
338
  },
290
339
  "command.execute.before": async (input, output) => {
291
340
  const sid = input.sessionID
@@ -296,7 +345,9 @@ export const QueuePlugin: Plugin = async ({ client }) => {
296
345
  const queued = parseSuffix(body)
297
346
  if (!queued) return
298
347
 
299
- if (!busy.has(sid)) {
348
+ const current = sessions.get(sid)
349
+ const shouldQueue = Boolean(current?.busy || current?.stopped)
350
+ if (!shouldQueue) {
300
351
  for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
301
352
  return
302
353
  }
@@ -305,13 +356,14 @@ export const QueuePlugin: Plugin = async ({ client }) => {
305
356
  return
306
357
  }
307
358
 
308
- const queued = parsePrefix(body)
309
- const op = parse(queued, parts.length)
359
+ const current = sessions.get(sid)
360
+ const shouldQueue = Boolean(current?.busy || current?.stopped)
361
+ const op = parse(parsePrefix(body), parts.length)
310
362
 
311
363
  if (control(op)) return stop(await manage(sid, op))
312
364
  if (op.kind === "invalid") return stop(op.message, "error")
313
365
 
314
- if (!busy.has(sid)) {
366
+ if (!shouldQueue) {
315
367
  if (op.kind === "shell") {
316
368
  await shell(sid, op.shell, await run(sid))
317
369
  throw new Error(HANDLED)
@@ -336,6 +388,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
336
388
  const request = parseInput(text.text)
337
389
  if (!request) return
338
390
 
391
+ const current = state(sid)
339
392
  const parts = files(output.parts)
340
393
  const op = parse(request, parts.length)
341
394
 
@@ -351,7 +404,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
351
404
  return
352
405
  }
353
406
 
354
- if (!busy.has(sid)) {
407
+ if (!current.busy && !current.stopped) {
355
408
  if (op.kind === "command") return
356
409
  if (op.kind === "shell") {
357
410
  hide(output.message.id, text)
@@ -385,7 +438,8 @@ export const QueuePlugin: Plugin = async ({ client }) => {
385
438
  }
386
439
  }
387
440
 
388
- enqueue(sid, item, op.front)
441
+ if (op.front) current.items.unshift(item)
442
+ else current.items.push(item)
389
443
  hide(output.message.id, text)
390
444
  await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
391
445
  },
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.2",
4
+ "version": "0.9.0",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",