opencode-queue 0.8.0 → 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.
- package/README.md +10 -1
- package/index.ts +116 -62
- 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
|
-
|
|
54
|
-
|
|
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 =>
|
|
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
|
|
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
|
|
133
|
-
const list =
|
|
134
|
-
if (!list
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
330
|
+
const current = state(event.properties.sessionID)
|
|
282
331
|
if (event.properties.status.type !== "idle") {
|
|
283
|
-
busy
|
|
332
|
+
current.busy = true
|
|
333
|
+
current.failed = false
|
|
284
334
|
return
|
|
285
335
|
}
|
|
286
336
|
|
|
287
|
-
busy
|
|
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
|
-
|
|
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
|
|
309
|
-
const
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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