opencode-queue 0.8.0 → 0.9.1

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 +136 -61
  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,19 @@ type EntryOp =
27
27
  | { kind: "command"; source: string; cmd: string; args: string }
28
28
  | { kind: "shell"; source: string; shell: string }
29
29
 
30
+ type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean }
31
+ type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean }
32
+
30
33
  type Op =
31
34
  | { kind: "list" }
32
35
  | { kind: "clear"; indices: number[] }
33
36
  | { kind: "flush" }
37
+ | { kind: "start" }
38
+ | { kind: "stop" }
34
39
  | { kind: "invalid"; message: string }
35
40
  | (EntryOp & { front: boolean })
36
41
 
37
- type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" }>
42
+ type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" | "start" | "stop" }>
38
43
 
39
44
  const brief = (body: string, files: number) => {
40
45
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
@@ -50,8 +55,18 @@ const parse = (input: QueueInput, files = 0): Op => {
50
55
  const text = input.body.trim()
51
56
  const front = input.front
52
57
  if (!front && !files) {
53
- if (!text || text === "list") return { kind: "list" }
54
- if (text === "flush") return { kind: "flush" }
58
+ switch (text) {
59
+ case "":
60
+ case "list":
61
+ return { kind: "list" }
62
+ case "flush":
63
+ return { kind: "flush" }
64
+ case "start":
65
+ return { kind: "start" }
66
+ case "stop":
67
+ return { kind: "stop" }
68
+ }
69
+
55
70
  const clear = text.match(/^clear(?:\s+([\s\S]+))?$/)
56
71
  if (clear) {
57
72
  const values = clear[1]?.trim().split(/\s+/) ?? []
@@ -87,7 +102,18 @@ const parseInput = (text: string): QueueInput | undefined => {
87
102
  const prefix = text.match(QUEUE)
88
103
  return prefix ? parsePrefix(prefix[1] ?? "") : parseSuffix(text)
89
104
  }
90
- const control = (op: Op): op is ControlOp => op.kind === "list" || op.kind === "clear" || op.kind === "flush"
105
+ const control = (op: Op): op is ControlOp => {
106
+ switch (op.kind) {
107
+ case "list":
108
+ case "clear":
109
+ case "flush":
110
+ case "start":
111
+ case "stop":
112
+ return true
113
+ default:
114
+ return false
115
+ }
116
+ }
91
117
  const plan = (event: unknown): event is Ask => {
92
118
  if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
93
119
  const question = (event as Ask).properties?.questions?.[0]
@@ -95,12 +121,19 @@ const plan = (event: unknown): event is Ask => {
95
121
  }
96
122
 
97
123
  export const QueuePlugin: Plugin = async ({ client }) => {
98
- const queue = new Map<string, Item[]>()
124
+ const sessions = new Map<string, State>()
99
125
  const hidden = new Set<string>()
100
- const busy = new Set<string>()
101
- const active = new Set<string>()
102
126
  const post = (client as unknown as { _client?: { post?: Post } })._client?.post
103
127
 
128
+ const state = (sid: string) => {
129
+ let current = sessions.get(sid)
130
+ if (!current) {
131
+ current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false }
132
+ sessions.set(sid, current)
133
+ }
134
+ return current
135
+ }
136
+
104
137
  const toast = (message: string, variant: "info" | "error", duration = 2500) =>
105
138
  client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
106
139
 
@@ -129,37 +162,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
129
162
 
130
163
  const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
131
164
 
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"
165
+ const clear = (current: State, indices: number[]) => {
166
+ const list = current.items
167
+ if (!list.length) return "Queue is empty"
159
168
 
160
169
  if (!indices.length) {
161
170
  const count = list.length
162
- queue.delete(sid)
171
+ list.splice(0)
163
172
  return `Cleared ${count} queued item${count === 1 ? "" : "s"}`
164
173
  }
165
174
 
@@ -168,7 +177,6 @@ export const QueuePlugin: Plugin = async ({ client }) => {
168
177
  if (missing.length) return `Queue item${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "does" : "do"} not exist`
169
178
 
170
179
  for (const index of targets.toReversed()) list.splice(index - 1, 1)
171
- if (!list.length) queue.delete(sid)
172
180
  return `Cleared queued item${targets.length === 1 ? "" : "s"} ${targets.join(", ")}`
173
181
  }
174
182
 
@@ -222,15 +230,42 @@ export const QueuePlugin: Plugin = async ({ client }) => {
222
230
  console.warn("QueuePlugin skipped queued item without replayable content")
223
231
  }
224
232
 
233
+ const advance = (sid: string) => {
234
+ const current = state(sid)
235
+ if (current.activity.kind !== "idle" || current.stopped || !current.items.length) return
236
+ void flush(sid, 1)
237
+ }
238
+
239
+ const settle = (sid: string, resume: boolean) => {
240
+ const current = state(sid)
241
+ current.activity = { kind: "idle" }
242
+ if (current.failed) {
243
+ current.failed = false
244
+ return
245
+ }
246
+
247
+ if (resume) advance(sid)
248
+ }
249
+
250
+ const idle = (sid: string) => {
251
+ const current = state(sid)
252
+ if (current.activity.kind === "sending") {
253
+ current.activity.idle = true
254
+ return
255
+ }
256
+ if (current.activity.kind === "busy") settle(sid, true)
257
+ }
258
+
225
259
  const flush = async (sid: string, count = Infinity) => {
226
- const items = take(sid, count)
260
+ const current = state(sid)
261
+ const items = current.items.splice(0, count)
227
262
  if (!items.length) return { sent: 0, failed: 0 }
228
263
 
229
- active.add(sid)
230
264
  let failed = 0
231
265
  const retry: Item[] = []
232
266
  try {
233
267
  for (const item of items) {
268
+ current.activity = { kind: "sending", idle: false }
234
269
  try {
235
270
  await replay(sid, item)
236
271
  } catch (error) {
@@ -241,25 +276,43 @@ export const QueuePlugin: Plugin = async ({ client }) => {
241
276
  }
242
277
  }
243
278
  } finally {
244
- requeue(sid, retry)
245
- active.delete(sid)
279
+ if (retry.length) current.items.unshift(...retry)
280
+ const replayCompleted = current.activity.kind === "sending" && current.activity.idle
281
+ if (replayCompleted) settle(sid, count === 1 && failed === 0)
282
+ else current.activity = failed ? { kind: "idle" } : { kind: "busy" }
246
283
  }
247
284
  return { sent: items.length - failed, failed }
248
285
  }
249
286
 
250
287
  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"
288
+ const current = state(sid)
289
+
290
+ switch (op.kind) {
291
+ case "list": {
292
+ const list =
293
+ current.items
294
+ .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
295
+ .join("\n") || "Queue is empty"
296
+ return current.stopped ? `${list}\nQueue is stopped` : list
297
+ }
298
+ case "clear":
299
+ return clear(current, op.indices)
300
+ case "stop":
301
+ current.stopped = true
302
+ return "Queue stopped"
303
+ case "start":
304
+ current.stopped = false
305
+ current.failed = false
306
+ advance(sid)
307
+ return "Queue started"
308
+ case "flush": {
309
+ const result = await flush(sid)
310
+ if (!result.sent && !result.failed) return "Queue is empty"
311
+
312
+ const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
313
+ return result.failed ? `${message}; ${result.failed} failed` : message
314
+ }
255
315
  }
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
316
  }
264
317
 
265
318
  return {
@@ -270,22 +323,39 @@ export const QueuePlugin: Plugin = async ({ client }) => {
270
323
  event: async ({ event }) => {
271
324
  if (plan(event)) {
272
325
  const sid = event.properties.sessionID
273
- if (!active.has(sid) && !queue.get(sid)?.length) return
326
+ const current = sessions.get(sid)
327
+ if (!current || (current.activity.kind !== "sending" && (current.stopped || !current.items.length))) return
274
328
  await no(event.properties.id)
275
329
  await toast("Declined plan approval to continue queued work", "info")
276
330
  return
277
331
  }
278
332
 
333
+ if (event.type === "session.error") {
334
+ const sid = event.properties.sessionID
335
+ if (!sid) {
336
+ console.warn("QueuePlugin could not suppress queued replay after session.error because the event has no sessionID")
337
+ return
338
+ }
339
+ state(sid).failed = true
340
+ return
341
+ }
342
+
343
+ if (event.type === "session.idle") {
344
+ idle(event.properties.sessionID)
345
+ return
346
+ }
347
+
279
348
  if (event.type !== "session.status") return
280
349
 
281
350
  const sid = event.properties.sessionID
351
+ const current = state(sid)
282
352
  if (event.properties.status.type !== "idle") {
283
- busy.add(sid)
353
+ if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
354
+ current.failed = false
284
355
  return
285
356
  }
286
357
 
287
- busy.delete(sid)
288
- void flush(sid, 1)
358
+ idle(sid)
289
359
  },
290
360
  "command.execute.before": async (input, output) => {
291
361
  const sid = input.sessionID
@@ -296,7 +366,9 @@ export const QueuePlugin: Plugin = async ({ client }) => {
296
366
  const queued = parseSuffix(body)
297
367
  if (!queued) return
298
368
 
299
- if (!busy.has(sid)) {
369
+ const current = sessions.get(sid)
370
+ const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
371
+ if (!shouldQueue) {
300
372
  for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
301
373
  return
302
374
  }
@@ -305,13 +377,14 @@ export const QueuePlugin: Plugin = async ({ client }) => {
305
377
  return
306
378
  }
307
379
 
308
- const queued = parsePrefix(body)
309
- const op = parse(queued, parts.length)
380
+ const current = sessions.get(sid)
381
+ const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
382
+ const op = parse(parsePrefix(body), parts.length)
310
383
 
311
384
  if (control(op)) return stop(await manage(sid, op))
312
385
  if (op.kind === "invalid") return stop(op.message, "error")
313
386
 
314
- if (!busy.has(sid)) {
387
+ if (!shouldQueue) {
315
388
  if (op.kind === "shell") {
316
389
  await shell(sid, op.shell, await run(sid))
317
390
  throw new Error(HANDLED)
@@ -336,6 +409,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
336
409
  const request = parseInput(text.text)
337
410
  if (!request) return
338
411
 
412
+ const current = state(sid)
339
413
  const parts = files(output.parts)
340
414
  const op = parse(request, parts.length)
341
415
 
@@ -351,7 +425,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
351
425
  return
352
426
  }
353
427
 
354
- if (!busy.has(sid)) {
428
+ if (current.activity.kind === "idle" && !current.stopped) {
355
429
  if (op.kind === "command") return
356
430
  if (op.kind === "shell") {
357
431
  hide(output.message.id, text)
@@ -385,7 +459,8 @@ export const QueuePlugin: Plugin = async ({ client }) => {
385
459
  }
386
460
  }
387
461
 
388
- enqueue(sid, item, op.front)
462
+ if (op.front) current.items.unshift(item)
463
+ else current.items.push(item)
389
464
  hide(output.message.id, text)
390
465
  await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
391
466
  },
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.8.0",
4
+ "version": "0.9.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",