opencode-queue 0.11.2 → 0.12.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 +2 -2
- package/index.ts +329 -125
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ When the session is busy:
|
|
|
83
83
|
- Queued entries replay in order after the session completes normally and becomes idle.
|
|
84
84
|
- `/queue front ...` puts an entry before the existing queued entries.
|
|
85
85
|
- Only one queued entry is sent per idle transition, so queued work runs one item at a time.
|
|
86
|
-
- Queued entries are kept in place after an error or
|
|
86
|
+
- Queued entries are kept in place after an error, abort, crash, or restart.
|
|
87
87
|
- `/queue stop` pauses automatic replay without clearing queued entries, and `/queue start` resumes it.
|
|
88
88
|
- `/queue flush` sends all queued entries immediately in one batch, even before the session is idle.
|
|
89
89
|
|
|
@@ -113,7 +113,7 @@ When the session is idle:
|
|
|
113
113
|
/queue clear 2 3
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
Queues are scoped to the current project and session. They are stored in OpenCode's user data directory and restored after OpenCode restarts or crashes. A send interrupted by a crash remains queued because the plugin cannot know whether OpenCode accepted it before exiting.
|
|
117
117
|
|
|
118
118
|
## Notes
|
|
119
119
|
|
package/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
|
|
3
3
|
import { HttpServerResponse } from "effect/unstable/http"
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import { dirname, join } from "node:path"
|
|
4
8
|
|
|
5
9
|
const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
|
|
6
10
|
const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
|
|
@@ -18,19 +22,22 @@ type Post = (input: { url: string; path?: Record<string, string>; body?: unknown
|
|
|
18
22
|
type QueueInput = { body: string; front: boolean }
|
|
19
23
|
|
|
20
24
|
type Item =
|
|
21
|
-
| { kind: "prompt"; info: Info;
|
|
25
|
+
| { kind: "prompt"; info: Info; body: string; parts: InputPart[] }
|
|
22
26
|
| { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
|
|
23
27
|
| { kind: "compact"; info: Info; source: string }
|
|
24
28
|
| { kind: "shell"; info: Info; source: string; shell: string }
|
|
25
29
|
|
|
26
30
|
type EntryOp =
|
|
27
|
-
| { kind: "prompt";
|
|
31
|
+
| { kind: "prompt"; body: string }
|
|
28
32
|
| { kind: "command"; source: string; cmd: string; args: string }
|
|
29
33
|
| { kind: "compact"; source: string }
|
|
30
34
|
| { kind: "shell"; source: string; shell: string }
|
|
31
35
|
|
|
32
|
-
type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean }
|
|
33
|
-
type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean }
|
|
36
|
+
type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean; items: Item[] }
|
|
37
|
+
type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean; hidden: Set<string> }
|
|
38
|
+
type Durable = Pick<State, "items" | "stopped" | "hidden">
|
|
39
|
+
type Store = { version: 1; projectID: string; sessions: Record<string, { items: Item[]; stopped: boolean; hidden: string[] }> }
|
|
40
|
+
type Placeholder = { id: string; part: TextPart }
|
|
34
41
|
|
|
35
42
|
type Op =
|
|
36
43
|
| { kind: "list" }
|
|
@@ -43,11 +50,6 @@ type Op =
|
|
|
43
50
|
|
|
44
51
|
type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" | "start" | "stop" }>
|
|
45
52
|
|
|
46
|
-
const brief = (body: string, files: number) => {
|
|
47
|
-
const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
|
|
48
|
-
return text.length > 72 ? `${text.slice(0, 69)}...` : text
|
|
49
|
-
}
|
|
50
|
-
|
|
51
53
|
const parsePrefix = (body: string): QueueInput => {
|
|
52
54
|
const match = body.trim().match(/^front(?:\s+([\s\S]*))?$/)
|
|
53
55
|
return match ? { body: match[1] ?? "", front: true } : { body, front: false }
|
|
@@ -97,7 +99,7 @@ const parse = (input: QueueInput, files = 0): Op => {
|
|
|
97
99
|
}
|
|
98
100
|
return { kind: "command", source: text, cmd, args, front }
|
|
99
101
|
}
|
|
100
|
-
return { kind: "prompt",
|
|
102
|
+
return { kind: "prompt", body: input.body, front }
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
const parseSuffix = (text: string): QueueInput | undefined => {
|
|
@@ -125,9 +127,14 @@ const control = (op: Op): op is ControlOp => {
|
|
|
125
127
|
return false
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
|
-
const shouldQueue = (state?: State) => Boolean(state && (state.activity.kind !== "idle" || state.stopped))
|
|
130
|
+
const shouldQueue = (state?: State) => Boolean(state && (state.activity.kind !== "idle" || state.stopped || state.items.length))
|
|
129
131
|
const shouldDeclinePlan = (state?: State) => Boolean(state && (state.activity.kind === "sending" || (!state.stopped && state.items.length)))
|
|
130
|
-
const itemText = (item: Item) =>
|
|
132
|
+
const itemText = (item: Item) => {
|
|
133
|
+
if (item.kind !== "prompt") return item.source
|
|
134
|
+
const body = item.body.trim()
|
|
135
|
+
const count = item.parts.filter((part) => part.type === "file").length
|
|
136
|
+
return body || `${count} attachment${count === 1 ? "" : "s"}`
|
|
137
|
+
}
|
|
131
138
|
// OpenCode's command hook has no cancel/noReply output. Throwing a raw Effect
|
|
132
139
|
// response is handled by OpenCode's HTTP layer as an empty successful command.
|
|
133
140
|
const handled = (): never => {
|
|
@@ -139,20 +146,150 @@ const plan = (event: unknown): event is Ask => {
|
|
|
139
146
|
return question?.header === "Build Agent" && question.question.includes("switch to the build agent")
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
|
|
149
|
+
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
|
150
|
+
const validInfo = (value: unknown): value is Info =>
|
|
151
|
+
record(value) &&
|
|
152
|
+
typeof value.agent === "string" &&
|
|
153
|
+
record(value.model) &&
|
|
154
|
+
typeof value.model.providerID === "string" &&
|
|
155
|
+
typeof value.model.modelID === "string" &&
|
|
156
|
+
(value.variant === undefined || typeof value.variant === "string")
|
|
157
|
+
const validPart = (value: unknown): value is InputPart => {
|
|
158
|
+
if (!record(value)) return false
|
|
159
|
+
switch (value.type) {
|
|
160
|
+
case "text":
|
|
161
|
+
return typeof value.text === "string"
|
|
162
|
+
case "file":
|
|
163
|
+
return typeof value.mime === "string" && typeof value.url === "string"
|
|
164
|
+
case "agent":
|
|
165
|
+
return typeof value.name === "string"
|
|
166
|
+
case "subtask":
|
|
167
|
+
return typeof value.prompt === "string" && typeof value.description === "string" && typeof value.agent === "string"
|
|
168
|
+
default:
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const validItem = (value: unknown): value is Item => {
|
|
173
|
+
if (!record(value) || !validInfo(value.info)) return false
|
|
174
|
+
switch (value.kind) {
|
|
175
|
+
case "prompt":
|
|
176
|
+
return typeof value.body === "string" && Array.isArray(value.parts) && value.parts.length > 0 && value.parts.every(validPart)
|
|
177
|
+
case "command":
|
|
178
|
+
return typeof value.source === "string" && typeof value.cmd === "string" && typeof value.args === "string" && Array.isArray(value.files) && value.files.every((part) => validPart(part) && part.type === "file")
|
|
179
|
+
case "compact":
|
|
180
|
+
return typeof value.source === "string"
|
|
181
|
+
case "shell":
|
|
182
|
+
return typeof value.source === "string" && typeof value.shell === "string"
|
|
183
|
+
default:
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const dataHome = () => {
|
|
189
|
+
if (process.env.XDG_DATA_HOME) return process.env.XDG_DATA_HOME
|
|
190
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA
|
|
191
|
+
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
|
|
192
|
+
return join(homedir(), ".local", "share")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
|
|
143
196
|
const sessions = new Map<string, State>()
|
|
144
|
-
const
|
|
197
|
+
const deleted = new Set<string>()
|
|
198
|
+
const enqueueTurns = new Map<string, Promise<unknown>>()
|
|
145
199
|
const post = (client as unknown as { _client?: { post?: Post } })._client?.post
|
|
200
|
+
const path = join(dataHome(), "opencode", "opencode-queue", `${createHash("sha256").update(project.id).digest("hex")}.json`)
|
|
201
|
+
let writes = Promise.resolve()
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"))
|
|
205
|
+
if (!record(parsed) || parsed.version !== 1 || parsed.projectID !== project.id || !record(parsed.sessions)) {
|
|
206
|
+
console.warn("QueuePlugin ignored invalid queue storage", path)
|
|
207
|
+
} else {
|
|
208
|
+
for (const [sid, value] of Object.entries(parsed.sessions)) {
|
|
209
|
+
if (!record(value) || typeof value.stopped !== "boolean" || !Array.isArray(value.items)) {
|
|
210
|
+
console.warn("QueuePlugin skipped invalid stored session", sid)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const items = value.items.filter(validItem)
|
|
214
|
+
if (items.length !== value.items.length) console.warn("QueuePlugin skipped invalid stored queue items", sid)
|
|
215
|
+
const validHidden = Array.isArray(value.hidden) && value.hidden.every((id) => typeof id === "string")
|
|
216
|
+
if (!validHidden) console.warn("QueuePlugin skipped invalid stored hidden messages", sid)
|
|
217
|
+
const hidden = new Set(validHidden ? (value.hidden as string[]) : [])
|
|
218
|
+
if (items.length || value.stopped || hidden.size) sessions.set(sid, { items, activity: { kind: "idle" }, stopped: value.stopped, failed: false, hidden })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load queue storage", error)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const save = async (sid?: string, draft?: Durable) => {
|
|
226
|
+
const stored: Store = { version: 1, projectID: project.id, sessions: {} }
|
|
227
|
+
for (const [id, current] of sessions) {
|
|
228
|
+
if (deleted.has(id) || (id === sid && !draft)) continue
|
|
229
|
+
const durable = id === sid ? draft! : current
|
|
230
|
+
const items = current.activity.kind === "sending" ? [...current.activity.items, ...durable.items] : durable.items
|
|
231
|
+
if (items.length || durable.stopped || durable.hidden.size) stored.sessions[id] = { items, stopped: durable.stopped, hidden: [...durable.hidden] }
|
|
232
|
+
}
|
|
233
|
+
const contents = `${JSON.stringify(stored, null, 2)}\n`
|
|
234
|
+
await mkdir(dirname(path), { recursive: true })
|
|
235
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
236
|
+
try {
|
|
237
|
+
await writeFile(temporary, contents, { mode: 0o600 })
|
|
238
|
+
await rename(temporary, path)
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary queue storage", error))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const serialize = <T>(action: () => Promise<T>) => {
|
|
245
|
+
const transaction = writes.then(action)
|
|
246
|
+
writes = transaction.then(() => undefined, () => undefined)
|
|
247
|
+
return transaction
|
|
248
|
+
}
|
|
146
249
|
|
|
147
250
|
const state = (sid: string) => {
|
|
148
251
|
let current = sessions.get(sid)
|
|
149
252
|
if (!current) {
|
|
150
|
-
current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false }
|
|
253
|
+
current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false, hidden: new Set() }
|
|
151
254
|
sessions.set(sid, current)
|
|
152
255
|
}
|
|
153
256
|
return current
|
|
154
257
|
}
|
|
155
258
|
|
|
259
|
+
const store = async (sid: string, current: State, draft: Durable, placeholder?: Placeholder) => {
|
|
260
|
+
try {
|
|
261
|
+
await save(sid, draft)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
current.items.splice(0, current.items.length, ...draft.items)
|
|
267
|
+
current.stopped = draft.stopped
|
|
268
|
+
current.hidden = draft.hidden
|
|
269
|
+
if (placeholder) Object.assign(placeholder.part, { text: "", synthetic: true, ignored: true })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const persist = <T>(sid: string, placeholder: Placeholder | undefined, mutate: (draft: Durable) => T) =>
|
|
273
|
+
serialize(async () => {
|
|
274
|
+
if (deleted.has(sid)) throw new Error(`QueuePlugin cannot persist queue state for deleted session ${sid}`)
|
|
275
|
+
const current = state(sid)
|
|
276
|
+
const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden) }
|
|
277
|
+
if (placeholder) draft.hidden.add(placeholder.id)
|
|
278
|
+
const value = mutate(draft)
|
|
279
|
+
await store(sid, current, draft, placeholder)
|
|
280
|
+
return value
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const orderedEnqueue = <T>(sid: string, action: () => Promise<T>) => {
|
|
284
|
+
const turn = (enqueueTurns.get(sid) ?? Promise.resolve()).catch(() => undefined).then(action)
|
|
285
|
+
enqueueTurns.set(sid, turn)
|
|
286
|
+
return turn.finally(() => {
|
|
287
|
+
if (enqueueTurns.get(sid) === turn) enqueueTurns.delete(sid)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const afterEnqueue = <T>(sid: string, action: () => Promise<T>) => (enqueueTurns.get(sid) ?? Promise.resolve()).catch(() => undefined).then(action)
|
|
292
|
+
|
|
156
293
|
const toast = (message: string, variant: "info" | "error", duration = 2500) =>
|
|
157
294
|
client.tui.showToast({ body: { message, variant, duration }, query: { directory } }).catch(() => undefined)
|
|
158
295
|
|
|
@@ -174,15 +311,9 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
174
311
|
if (!result?.response?.ok) console.warn("QueuePlugin failed to answer plan prompt", result?.error ?? result?.response?.status)
|
|
175
312
|
}
|
|
176
313
|
|
|
177
|
-
const hide = (id: string, part: TextPart) => {
|
|
178
|
-
hidden.add(id)
|
|
179
|
-
Object.assign(part, { text: "", synthetic: true, ignored: true })
|
|
180
|
-
}
|
|
181
|
-
|
|
182
314
|
const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
183
315
|
|
|
184
|
-
const clear = (
|
|
185
|
-
const list = current.items
|
|
316
|
+
const clear = (list: Item[], indices: number[]) => {
|
|
186
317
|
if (!list.length) return "Queue is empty"
|
|
187
318
|
|
|
188
319
|
if (!indices.length) {
|
|
@@ -223,7 +354,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
223
354
|
|
|
224
355
|
const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant })
|
|
225
356
|
|
|
226
|
-
const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
|
|
357
|
+
const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command }, throwOnError: true })
|
|
227
358
|
// TUI command events target the focused session; queued replay must target the original session.
|
|
228
359
|
const compact = (sid: string, info: Info) =>
|
|
229
360
|
client.session.summarize({
|
|
@@ -248,34 +379,23 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
248
379
|
arguments: item.args,
|
|
249
380
|
parts: item.files,
|
|
250
381
|
} as any,
|
|
382
|
+
throwOnError: true,
|
|
251
383
|
})
|
|
252
384
|
case "prompt": {
|
|
253
|
-
if (!item.parts.length) {
|
|
254
|
-
console.warn("QueuePlugin skipped queued item without replayable content")
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
|
|
258
385
|
const parts = item.parts.map((part) => ({ ...part, id: undefined }))
|
|
259
|
-
return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any })
|
|
386
|
+
return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any, throwOnError: true })
|
|
260
387
|
}
|
|
261
388
|
}
|
|
262
389
|
}
|
|
263
390
|
|
|
264
391
|
const advance = (sid: string) => {
|
|
392
|
+
if (deleted.has(sid)) return
|
|
265
393
|
const current = state(sid)
|
|
266
|
-
if (current.activity.kind !== "idle" || current.stopped || !current.items.length) return
|
|
267
|
-
void flush(sid, 1)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const current = state(sid)
|
|
272
|
-
current.activity = { kind: "idle" }
|
|
273
|
-
if (current.failed) {
|
|
274
|
-
current.failed = false
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (resume) advance(sid)
|
|
394
|
+
if (current.activity.kind !== "idle" || current.stopped || current.failed || !current.items.length) return
|
|
395
|
+
void flush(sid, 1).catch(async (error) => {
|
|
396
|
+
console.error("QueuePlugin could not advance the persisted queue", error)
|
|
397
|
+
await toast(`Queue persistence failed: ${error instanceof Error ? error.message : String(error)}`, "error", 5000)
|
|
398
|
+
})
|
|
279
399
|
}
|
|
280
400
|
|
|
281
401
|
const idle = (sid: string) => {
|
|
@@ -284,69 +404,113 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
284
404
|
current.activity.idle = true
|
|
285
405
|
return
|
|
286
406
|
}
|
|
287
|
-
if (current.activity.kind
|
|
407
|
+
if (current.activity.kind !== "busy") return
|
|
408
|
+
current.activity = { kind: "idle" }
|
|
409
|
+
if (!current.failed) advance(sid)
|
|
288
410
|
}
|
|
289
411
|
|
|
290
|
-
const flush = async (sid: string, count = Infinity) => {
|
|
291
|
-
|
|
292
|
-
const items = current.items.splice(0, count)
|
|
293
|
-
if (!items.length) return { sent: 0, failed: 0 }
|
|
412
|
+
const flush = async (sid: string, count = Infinity, placeholder?: Placeholder) => {
|
|
413
|
+
type Reservation = "active" | undefined | { current: State; items: Item[]; sending: Extract<Activity, { kind: "sending" }> }
|
|
294
414
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
415
|
+
const reservation = await serialize<Reservation>(async () => {
|
|
416
|
+
if (deleted.has(sid)) return undefined
|
|
417
|
+
const current = state(sid)
|
|
418
|
+
if (count === 1 && !placeholder && (current.activity.kind !== "idle" || current.stopped || !current.items.length)) return undefined
|
|
419
|
+
|
|
420
|
+
const items = current.items.slice(0, count)
|
|
421
|
+
if (placeholder) {
|
|
422
|
+
const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id) }
|
|
423
|
+
await store(sid, current, draft, placeholder)
|
|
424
|
+
if (deleted.has(sid)) return undefined
|
|
425
|
+
}
|
|
426
|
+
if (current.activity.kind === "sending") return "active"
|
|
427
|
+
if (!items.length) return undefined
|
|
428
|
+
|
|
429
|
+
const sending: Extract<Activity, { kind: "sending" }> = { kind: "sending", idle: false, items }
|
|
430
|
+
current.items.splice(0, items.length)
|
|
431
|
+
current.activity = sending
|
|
432
|
+
return { current, items, sending }
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
if (reservation === "active") {
|
|
436
|
+
console.warn("QueuePlugin ignored a concurrent queue flush", sid)
|
|
437
|
+
return undefined
|
|
317
438
|
}
|
|
439
|
+
if (!reservation) return { sent: 0, failed: 0 }
|
|
440
|
+
|
|
441
|
+
const { current, items, sending } = reservation
|
|
442
|
+
const results = await Promise.all(
|
|
443
|
+
items.map(async (item) => {
|
|
444
|
+
try {
|
|
445
|
+
await replay(sid, item)
|
|
446
|
+
return { item, failed: false }
|
|
447
|
+
} catch (error) {
|
|
448
|
+
console.error("QueuePlugin failed to flush queued input", error)
|
|
449
|
+
await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
450
|
+
return { item, failed: true }
|
|
451
|
+
}
|
|
452
|
+
}),
|
|
453
|
+
)
|
|
454
|
+
const retry = results.flatMap((result) => (result.failed ? [result.item] : []))
|
|
455
|
+
const resume = await serialize(async () => {
|
|
456
|
+
if (sessions.get(sid) !== current) return false
|
|
457
|
+
if (current.activity !== sending) throw new Error(`QueuePlugin lost track of in-flight queued items for session ${sid}`)
|
|
458
|
+
|
|
459
|
+
sending.items = retry
|
|
460
|
+
try {
|
|
461
|
+
await save()
|
|
462
|
+
} catch (error) {
|
|
463
|
+
sending.items = items
|
|
464
|
+
current.items.unshift(...items)
|
|
465
|
+
current.activity = sending.idle ? { kind: "idle" } : { kind: "busy" }
|
|
466
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
467
|
+
throw error
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const failed = current.failed
|
|
471
|
+
if (retry.length) current.items.unshift(...retry)
|
|
472
|
+
if (sending.idle) {
|
|
473
|
+
current.activity = { kind: "idle" }
|
|
474
|
+
} else current.activity = retry.length ? { kind: "idle" } : { kind: "busy" }
|
|
475
|
+
return sending.idle && !failed && count === 1 && !retry.length
|
|
476
|
+
})
|
|
477
|
+
if (resume) advance(sid)
|
|
318
478
|
return { sent: items.length - retry.length, failed: retry.length }
|
|
319
479
|
}
|
|
320
480
|
|
|
321
|
-
const manage = async (sid: string, op: ControlOp) => {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
case "clear":
|
|
330
|
-
return clear(current, op.indices)
|
|
331
|
-
case "stop":
|
|
332
|
-
current.stopped = true
|
|
333
|
-
return "Queue stopped"
|
|
334
|
-
case "start":
|
|
335
|
-
current.stopped = false
|
|
336
|
-
current.failed = false
|
|
337
|
-
advance(sid)
|
|
338
|
-
return "Queue started"
|
|
339
|
-
case "flush": {
|
|
340
|
-
const result = await flush(sid)
|
|
341
|
-
if (!result.sent && !result.failed) return "Queue is empty"
|
|
481
|
+
const manage = async (sid: string, op: ControlOp, placeholder?: Placeholder) => {
|
|
482
|
+
if (op.kind === "flush") {
|
|
483
|
+
const result = await flush(sid, Infinity, placeholder)
|
|
484
|
+
if (!result) return "Queue is already flushing"
|
|
485
|
+
if (!result.sent && !result.failed) return "Queue is empty"
|
|
486
|
+
const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
|
|
487
|
+
return result.failed ? `${message}; ${result.failed} failed` : message
|
|
488
|
+
}
|
|
342
489
|
|
|
343
|
-
|
|
344
|
-
|
|
490
|
+
const message = await persist(sid, placeholder, (draft) => {
|
|
491
|
+
switch (op.kind) {
|
|
492
|
+
case "list": {
|
|
493
|
+
const list = draft.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
|
|
494
|
+
return draft.stopped ? `${list}\nQueue is stopped` : list
|
|
495
|
+
}
|
|
496
|
+
case "clear":
|
|
497
|
+
return clear(draft.items, op.indices)
|
|
498
|
+
case "stop":
|
|
499
|
+
draft.stopped = true
|
|
500
|
+
return "Queue stopped"
|
|
501
|
+
case "start":
|
|
502
|
+
draft.stopped = false
|
|
503
|
+
return "Queue started"
|
|
345
504
|
}
|
|
505
|
+
})
|
|
506
|
+
if (op.kind === "start") {
|
|
507
|
+
state(sid).failed = false
|
|
508
|
+
advance(sid)
|
|
346
509
|
}
|
|
510
|
+
return message
|
|
347
511
|
}
|
|
348
512
|
|
|
349
|
-
|
|
513
|
+
const hooks: Awaited<ReturnType<Plugin>> = {
|
|
350
514
|
config: async (cfg) => {
|
|
351
515
|
cfg.command ??= {}
|
|
352
516
|
cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
|
|
@@ -366,11 +530,29 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
366
530
|
console.warn("QueuePlugin could not suppress queued replay after session.error because the event has no sessionID")
|
|
367
531
|
return
|
|
368
532
|
}
|
|
533
|
+
if (deleted.has(sid)) return
|
|
369
534
|
state(sid).failed = true
|
|
370
535
|
return
|
|
371
536
|
}
|
|
372
537
|
|
|
538
|
+
if (event.type === "session.deleted") {
|
|
539
|
+
const sid = event.properties.info.id
|
|
540
|
+
if (deleted.has(sid) && !sessions.has(sid)) return
|
|
541
|
+
deleted.add(sid)
|
|
542
|
+
await serialize(async () => {
|
|
543
|
+
try {
|
|
544
|
+
await save(sid)
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
547
|
+
throw error
|
|
548
|
+
}
|
|
549
|
+
sessions.delete(sid)
|
|
550
|
+
})
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
373
554
|
if (event.type === "session.idle") {
|
|
555
|
+
if (deleted.has(event.properties.sessionID)) return
|
|
374
556
|
idle(event.properties.sessionID)
|
|
375
557
|
return
|
|
376
558
|
}
|
|
@@ -378,6 +560,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
378
560
|
if (event.type !== "session.status") return
|
|
379
561
|
|
|
380
562
|
const sid = event.properties.sessionID
|
|
563
|
+
if (deleted.has(sid)) return
|
|
381
564
|
const current = state(sid)
|
|
382
565
|
if (event.properties.status.type !== "idle") {
|
|
383
566
|
if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
|
|
@@ -407,7 +590,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
407
590
|
|
|
408
591
|
const op = parse(parsePrefix(body), parts.length)
|
|
409
592
|
|
|
410
|
-
if (control(op)) return stop(await manage(sid, op))
|
|
593
|
+
if (control(op)) return stop(await afterEnqueue(sid, () => manage(sid, op)))
|
|
411
594
|
if (op.kind === "invalid") return stop(op.message, "error")
|
|
412
595
|
|
|
413
596
|
if (!shouldQueue(sessions.get(sid))) {
|
|
@@ -434,6 +617,10 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
434
617
|
},
|
|
435
618
|
"chat.message": async (input, output) => {
|
|
436
619
|
const sid = input.sessionID
|
|
620
|
+
if (deleted.has(sid)) {
|
|
621
|
+
console.warn("QueuePlugin ignored input for a deleted session", sid)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
437
624
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
438
625
|
if (!text) return
|
|
439
626
|
|
|
@@ -444,28 +631,28 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
444
631
|
const parts = files(output.parts)
|
|
445
632
|
const op = parse(request, parts.length)
|
|
446
633
|
const info = { agent: input.agent ?? output.message.agent, model: input.model ?? output.message.model, variant: input.variant }
|
|
634
|
+
const placeholder = { id: output.message.id, part: text }
|
|
447
635
|
|
|
448
636
|
if (control(op)) {
|
|
449
|
-
|
|
450
|
-
await toast(await manage(sid, op), "info", 5000)
|
|
637
|
+
await toast(await afterEnqueue(sid, () => manage(sid, op, placeholder)), "info", 5000)
|
|
451
638
|
return
|
|
452
639
|
}
|
|
453
640
|
|
|
454
641
|
if (op.kind === "invalid") {
|
|
455
|
-
|
|
642
|
+
await persist(sid, placeholder, () => undefined)
|
|
456
643
|
await toast(op.message, "error", 5000)
|
|
457
644
|
return
|
|
458
645
|
}
|
|
459
646
|
|
|
460
|
-
if (
|
|
647
|
+
if (!shouldQueue(current)) {
|
|
461
648
|
if (op.kind === "command") return
|
|
462
649
|
if (op.kind === "compact") {
|
|
463
|
-
|
|
650
|
+
await persist(sid, placeholder, () => undefined)
|
|
464
651
|
await compact(sid, info)
|
|
465
652
|
return
|
|
466
653
|
}
|
|
467
654
|
if (op.kind === "shell") {
|
|
468
|
-
|
|
655
|
+
await persist(sid, placeholder, () => undefined)
|
|
469
656
|
await shell(sid, op.shell, info)
|
|
470
657
|
return
|
|
471
658
|
}
|
|
@@ -473,37 +660,54 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
473
660
|
return
|
|
474
661
|
}
|
|
475
662
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
663
|
+
return orderedEnqueue(sid, async () => {
|
|
664
|
+
if (deleted.has(sid)) {
|
|
665
|
+
console.warn("QueuePlugin stopped queueing input for a deleted session", sid)
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
const prior = await latest(sid)
|
|
669
|
+
if (deleted.has(sid)) {
|
|
670
|
+
console.warn("QueuePlugin stopped queueing input for a deleted session", sid)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
if (prior) Object.assign(output.message, opts(prior))
|
|
674
|
+
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
|
|
675
|
+
let item: Item
|
|
676
|
+
if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
|
|
677
|
+
else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
|
|
678
|
+
else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
|
|
679
|
+
else {
|
|
680
|
+
item = {
|
|
681
|
+
kind: "prompt",
|
|
682
|
+
info,
|
|
683
|
+
body: op.body,
|
|
684
|
+
parts: output.parts.flatMap((part): InputPart[] => {
|
|
685
|
+
if (part.type === "text") return part.id === text.id ? (request.body ? [{ ...part, text: request.body }] : []) : [{ ...part }]
|
|
686
|
+
if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
|
|
687
|
+
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
688
|
+
return []
|
|
689
|
+
}),
|
|
690
|
+
}
|
|
495
691
|
}
|
|
496
|
-
}
|
|
497
692
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
693
|
+
await persist(sid, placeholder, (draft) => {
|
|
694
|
+
if (op.front) draft.items.unshift(item)
|
|
695
|
+
else draft.items.push(item)
|
|
696
|
+
})
|
|
697
|
+
advance(sid)
|
|
698
|
+
await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
|
|
699
|
+
})
|
|
502
700
|
},
|
|
503
701
|
"experimental.chat.messages.transform": async (_, output) => {
|
|
504
|
-
output.messages = output.messages.filter((msg) =>
|
|
702
|
+
output.messages = output.messages.filter((msg) => {
|
|
703
|
+
for (const current of sessions.values()) if (current.hidden.has(msg.info.id)) return false
|
|
704
|
+
return true
|
|
705
|
+
})
|
|
505
706
|
},
|
|
506
707
|
}
|
|
708
|
+
|
|
709
|
+
for (const [sid, current] of sessions) if (current.items.length && !current.stopped) setTimeout(() => advance(sid), 0)
|
|
710
|
+
return hooks
|
|
507
711
|
}
|
|
508
712
|
|
|
509
713
|
export default QueuePlugin
|
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.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Queue OpenCode prompts and slash commands until the agent is idle",
|
|
7
7
|
"main": "./index.ts",
|
|
@@ -29,12 +29,13 @@
|
|
|
29
29
|
".": "./index.ts"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=20"
|
|
32
|
+
"node": ">=20.17.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
+
"test": "tsx --test index.test.js",
|
|
38
39
|
"typecheck": "tsc --noEmit"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
@@ -43,6 +44,8 @@
|
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@opencode-ai/plugin": "^1.14.37",
|
|
45
46
|
"@opencode-ai/sdk": "^1.14.37",
|
|
47
|
+
"@types/node": "^20.0.0",
|
|
48
|
+
"tsx": "^4.0.0",
|
|
46
49
|
"typescript": "^6.0.3"
|
|
47
50
|
},
|
|
48
51
|
"overrides": {
|