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.
- package/README.md +10 -1
- package/index.ts +136 -61
- 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
|
-
|
|
54
|
-
|
|
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 =>
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
+
if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
|
|
354
|
+
current.failed = false
|
|
284
355
|
return
|
|
285
356
|
}
|
|
286
357
|
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
const
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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