opencode-queue 0.11.2 → 0.12.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 +2 -2
- package/index.ts +331 -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, so restored queues stay paused until explicitly started.
|
|
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,152 @@ 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
|
+
const stopped = value.stopped || items.length > 0
|
|
219
|
+
if (items.length && !value.stopped) console.warn("QueuePlugin paused restored queued input to prevent duplicate replay after a restart", sid)
|
|
220
|
+
if (items.length || stopped || hidden.size) sessions.set(sid, { items, activity: { kind: "idle" }, stopped, failed: false, hidden })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load queue storage", error)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const save = async (sid?: string, draft?: Durable) => {
|
|
228
|
+
const stored: Store = { version: 1, projectID: project.id, sessions: {} }
|
|
229
|
+
for (const [id, current] of sessions) {
|
|
230
|
+
if (deleted.has(id) || (id === sid && !draft)) continue
|
|
231
|
+
const durable = id === sid ? draft! : current
|
|
232
|
+
const items = current.activity.kind === "sending" ? [...current.activity.items, ...durable.items] : durable.items
|
|
233
|
+
if (items.length || durable.stopped || durable.hidden.size) stored.sessions[id] = { items, stopped: durable.stopped, hidden: [...durable.hidden] }
|
|
234
|
+
}
|
|
235
|
+
const contents = `${JSON.stringify(stored, null, 2)}\n`
|
|
236
|
+
await mkdir(dirname(path), { recursive: true })
|
|
237
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
238
|
+
try {
|
|
239
|
+
await writeFile(temporary, contents, { mode: 0o600 })
|
|
240
|
+
await rename(temporary, path)
|
|
241
|
+
} finally {
|
|
242
|
+
await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary queue storage", error))
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const serialize = <T>(action: () => Promise<T>) => {
|
|
247
|
+
const transaction = writes.then(action)
|
|
248
|
+
writes = transaction.then(() => undefined, () => undefined)
|
|
249
|
+
return transaction
|
|
250
|
+
}
|
|
146
251
|
|
|
147
252
|
const state = (sid: string) => {
|
|
148
253
|
let current = sessions.get(sid)
|
|
149
254
|
if (!current) {
|
|
150
|
-
current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false }
|
|
255
|
+
current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false, hidden: new Set() }
|
|
151
256
|
sessions.set(sid, current)
|
|
152
257
|
}
|
|
153
258
|
return current
|
|
154
259
|
}
|
|
155
260
|
|
|
261
|
+
const store = async (sid: string, current: State, draft: Durable, placeholder?: Placeholder) => {
|
|
262
|
+
try {
|
|
263
|
+
await save(sid, draft)
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
current.items.splice(0, current.items.length, ...draft.items)
|
|
269
|
+
current.stopped = draft.stopped
|
|
270
|
+
current.hidden = draft.hidden
|
|
271
|
+
if (placeholder) Object.assign(placeholder.part, { text: "", synthetic: true, ignored: true })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const persist = <T>(sid: string, placeholder: Placeholder | undefined, mutate: (draft: Durable) => T) =>
|
|
275
|
+
serialize(async () => {
|
|
276
|
+
if (deleted.has(sid)) throw new Error(`QueuePlugin cannot persist queue state for deleted session ${sid}`)
|
|
277
|
+
const current = state(sid)
|
|
278
|
+
const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden) }
|
|
279
|
+
if (placeholder) draft.hidden.add(placeholder.id)
|
|
280
|
+
const value = mutate(draft)
|
|
281
|
+
await store(sid, current, draft, placeholder)
|
|
282
|
+
return value
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const orderedEnqueue = <T>(sid: string, action: () => Promise<T>) => {
|
|
286
|
+
const turn = (enqueueTurns.get(sid) ?? Promise.resolve()).catch(() => undefined).then(action)
|
|
287
|
+
enqueueTurns.set(sid, turn)
|
|
288
|
+
return turn.finally(() => {
|
|
289
|
+
if (enqueueTurns.get(sid) === turn) enqueueTurns.delete(sid)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const afterEnqueue = <T>(sid: string, action: () => Promise<T>) => (enqueueTurns.get(sid) ?? Promise.resolve()).catch(() => undefined).then(action)
|
|
294
|
+
|
|
156
295
|
const toast = (message: string, variant: "info" | "error", duration = 2500) =>
|
|
157
296
|
client.tui.showToast({ body: { message, variant, duration }, query: { directory } }).catch(() => undefined)
|
|
158
297
|
|
|
@@ -174,15 +313,9 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
174
313
|
if (!result?.response?.ok) console.warn("QueuePlugin failed to answer plan prompt", result?.error ?? result?.response?.status)
|
|
175
314
|
}
|
|
176
315
|
|
|
177
|
-
const hide = (id: string, part: TextPart) => {
|
|
178
|
-
hidden.add(id)
|
|
179
|
-
Object.assign(part, { text: "", synthetic: true, ignored: true })
|
|
180
|
-
}
|
|
181
|
-
|
|
182
316
|
const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
183
317
|
|
|
184
|
-
const clear = (
|
|
185
|
-
const list = current.items
|
|
318
|
+
const clear = (list: Item[], indices: number[]) => {
|
|
186
319
|
if (!list.length) return "Queue is empty"
|
|
187
320
|
|
|
188
321
|
if (!indices.length) {
|
|
@@ -223,7 +356,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
223
356
|
|
|
224
357
|
const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant })
|
|
225
358
|
|
|
226
|
-
const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
|
|
359
|
+
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
360
|
// TUI command events target the focused session; queued replay must target the original session.
|
|
228
361
|
const compact = (sid: string, info: Info) =>
|
|
229
362
|
client.session.summarize({
|
|
@@ -248,34 +381,23 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
248
381
|
arguments: item.args,
|
|
249
382
|
parts: item.files,
|
|
250
383
|
} as any,
|
|
384
|
+
throwOnError: true,
|
|
251
385
|
})
|
|
252
386
|
case "prompt": {
|
|
253
|
-
if (!item.parts.length) {
|
|
254
|
-
console.warn("QueuePlugin skipped queued item without replayable content")
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
|
|
258
387
|
const parts = item.parts.map((part) => ({ ...part, id: undefined }))
|
|
259
|
-
return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any })
|
|
388
|
+
return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any, throwOnError: true })
|
|
260
389
|
}
|
|
261
390
|
}
|
|
262
391
|
}
|
|
263
392
|
|
|
264
393
|
const advance = (sid: string) => {
|
|
394
|
+
if (deleted.has(sid)) return
|
|
265
395
|
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)
|
|
396
|
+
if (current.activity.kind !== "idle" || current.stopped || current.failed || !current.items.length) return
|
|
397
|
+
void flush(sid, 1).catch(async (error) => {
|
|
398
|
+
console.error("QueuePlugin could not advance the persisted queue", error)
|
|
399
|
+
await toast(`Queue persistence failed: ${error instanceof Error ? error.message : String(error)}`, "error", 5000)
|
|
400
|
+
})
|
|
279
401
|
}
|
|
280
402
|
|
|
281
403
|
const idle = (sid: string) => {
|
|
@@ -284,69 +406,113 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
284
406
|
current.activity.idle = true
|
|
285
407
|
return
|
|
286
408
|
}
|
|
287
|
-
if (current.activity.kind
|
|
409
|
+
if (current.activity.kind !== "busy") return
|
|
410
|
+
current.activity = { kind: "idle" }
|
|
411
|
+
if (!current.failed) advance(sid)
|
|
288
412
|
}
|
|
289
413
|
|
|
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 }
|
|
414
|
+
const flush = async (sid: string, count = Infinity, placeholder?: Placeholder) => {
|
|
415
|
+
type Reservation = "active" | undefined | { current: State; items: Item[]; sending: Extract<Activity, { kind: "sending" }> }
|
|
294
416
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
417
|
+
const reservation = await serialize<Reservation>(async () => {
|
|
418
|
+
if (deleted.has(sid)) return undefined
|
|
419
|
+
const current = state(sid)
|
|
420
|
+
if (count === 1 && !placeholder && (current.activity.kind !== "idle" || current.stopped || !current.items.length)) return undefined
|
|
421
|
+
|
|
422
|
+
const items = current.items.slice(0, count)
|
|
423
|
+
if (placeholder) {
|
|
424
|
+
const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id) }
|
|
425
|
+
await store(sid, current, draft, placeholder)
|
|
426
|
+
if (deleted.has(sid)) return undefined
|
|
427
|
+
}
|
|
428
|
+
if (current.activity.kind === "sending") return "active"
|
|
429
|
+
if (!items.length) return undefined
|
|
430
|
+
|
|
431
|
+
const sending: Extract<Activity, { kind: "sending" }> = { kind: "sending", idle: false, items }
|
|
432
|
+
current.items.splice(0, items.length)
|
|
433
|
+
current.activity = sending
|
|
434
|
+
return { current, items, sending }
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if (reservation === "active") {
|
|
438
|
+
console.warn("QueuePlugin ignored a concurrent queue flush", sid)
|
|
439
|
+
return undefined
|
|
317
440
|
}
|
|
441
|
+
if (!reservation) return { sent: 0, failed: 0 }
|
|
442
|
+
|
|
443
|
+
const { current, items, sending } = reservation
|
|
444
|
+
const results = await Promise.all(
|
|
445
|
+
items.map(async (item) => {
|
|
446
|
+
try {
|
|
447
|
+
await replay(sid, item)
|
|
448
|
+
return { item, failed: false }
|
|
449
|
+
} catch (error) {
|
|
450
|
+
console.error("QueuePlugin failed to flush queued input", error)
|
|
451
|
+
await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
452
|
+
return { item, failed: true }
|
|
453
|
+
}
|
|
454
|
+
}),
|
|
455
|
+
)
|
|
456
|
+
const retry = results.flatMap((result) => (result.failed ? [result.item] : []))
|
|
457
|
+
const resume = await serialize(async () => {
|
|
458
|
+
if (sessions.get(sid) !== current) return false
|
|
459
|
+
if (current.activity !== sending) throw new Error(`QueuePlugin lost track of in-flight queued items for session ${sid}`)
|
|
460
|
+
|
|
461
|
+
sending.items = retry
|
|
462
|
+
try {
|
|
463
|
+
await save()
|
|
464
|
+
} catch (error) {
|
|
465
|
+
sending.items = items
|
|
466
|
+
current.items.unshift(...items)
|
|
467
|
+
current.activity = sending.idle ? { kind: "idle" } : { kind: "busy" }
|
|
468
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
469
|
+
throw error
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const failed = current.failed
|
|
473
|
+
if (retry.length) current.items.unshift(...retry)
|
|
474
|
+
if (sending.idle) {
|
|
475
|
+
current.activity = { kind: "idle" }
|
|
476
|
+
} else current.activity = retry.length ? { kind: "idle" } : { kind: "busy" }
|
|
477
|
+
return sending.idle && !failed && count === 1 && !retry.length
|
|
478
|
+
})
|
|
479
|
+
if (resume) advance(sid)
|
|
318
480
|
return { sent: items.length - retry.length, failed: retry.length }
|
|
319
481
|
}
|
|
320
482
|
|
|
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"
|
|
483
|
+
const manage = async (sid: string, op: ControlOp, placeholder?: Placeholder) => {
|
|
484
|
+
if (op.kind === "flush") {
|
|
485
|
+
const result = await flush(sid, Infinity, placeholder)
|
|
486
|
+
if (!result) return "Queue is already flushing"
|
|
487
|
+
if (!result.sent && !result.failed) return "Queue is empty"
|
|
488
|
+
const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
|
|
489
|
+
return result.failed ? `${message}; ${result.failed} failed` : message
|
|
490
|
+
}
|
|
342
491
|
|
|
343
|
-
|
|
344
|
-
|
|
492
|
+
const message = await persist(sid, placeholder, (draft) => {
|
|
493
|
+
switch (op.kind) {
|
|
494
|
+
case "list": {
|
|
495
|
+
const list = draft.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
|
|
496
|
+
return draft.stopped ? `${list}\nQueue is stopped` : list
|
|
497
|
+
}
|
|
498
|
+
case "clear":
|
|
499
|
+
return clear(draft.items, op.indices)
|
|
500
|
+
case "stop":
|
|
501
|
+
draft.stopped = true
|
|
502
|
+
return "Queue stopped"
|
|
503
|
+
case "start":
|
|
504
|
+
draft.stopped = false
|
|
505
|
+
return "Queue started"
|
|
345
506
|
}
|
|
507
|
+
})
|
|
508
|
+
if (op.kind === "start") {
|
|
509
|
+
state(sid).failed = false
|
|
510
|
+
advance(sid)
|
|
346
511
|
}
|
|
512
|
+
return message
|
|
347
513
|
}
|
|
348
514
|
|
|
349
|
-
|
|
515
|
+
const hooks: Awaited<ReturnType<Plugin>> = {
|
|
350
516
|
config: async (cfg) => {
|
|
351
517
|
cfg.command ??= {}
|
|
352
518
|
cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
|
|
@@ -366,11 +532,29 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
366
532
|
console.warn("QueuePlugin could not suppress queued replay after session.error because the event has no sessionID")
|
|
367
533
|
return
|
|
368
534
|
}
|
|
535
|
+
if (deleted.has(sid)) return
|
|
369
536
|
state(sid).failed = true
|
|
370
537
|
return
|
|
371
538
|
}
|
|
372
539
|
|
|
540
|
+
if (event.type === "session.deleted") {
|
|
541
|
+
const sid = event.properties.info.id
|
|
542
|
+
if (deleted.has(sid) && !sessions.has(sid)) return
|
|
543
|
+
deleted.add(sid)
|
|
544
|
+
await serialize(async () => {
|
|
545
|
+
try {
|
|
546
|
+
await save(sid)
|
|
547
|
+
} catch (error) {
|
|
548
|
+
console.error("QueuePlugin failed to persist queues", error)
|
|
549
|
+
throw error
|
|
550
|
+
}
|
|
551
|
+
sessions.delete(sid)
|
|
552
|
+
})
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
373
556
|
if (event.type === "session.idle") {
|
|
557
|
+
if (deleted.has(event.properties.sessionID)) return
|
|
374
558
|
idle(event.properties.sessionID)
|
|
375
559
|
return
|
|
376
560
|
}
|
|
@@ -378,6 +562,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
378
562
|
if (event.type !== "session.status") return
|
|
379
563
|
|
|
380
564
|
const sid = event.properties.sessionID
|
|
565
|
+
if (deleted.has(sid)) return
|
|
381
566
|
const current = state(sid)
|
|
382
567
|
if (event.properties.status.type !== "idle") {
|
|
383
568
|
if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
|
|
@@ -407,7 +592,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
407
592
|
|
|
408
593
|
const op = parse(parsePrefix(body), parts.length)
|
|
409
594
|
|
|
410
|
-
if (control(op)) return stop(await manage(sid, op))
|
|
595
|
+
if (control(op)) return stop(await afterEnqueue(sid, () => manage(sid, op)))
|
|
411
596
|
if (op.kind === "invalid") return stop(op.message, "error")
|
|
412
597
|
|
|
413
598
|
if (!shouldQueue(sessions.get(sid))) {
|
|
@@ -434,6 +619,10 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
434
619
|
},
|
|
435
620
|
"chat.message": async (input, output) => {
|
|
436
621
|
const sid = input.sessionID
|
|
622
|
+
if (deleted.has(sid)) {
|
|
623
|
+
console.warn("QueuePlugin ignored input for a deleted session", sid)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
437
626
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
438
627
|
if (!text) return
|
|
439
628
|
|
|
@@ -444,28 +633,28 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
444
633
|
const parts = files(output.parts)
|
|
445
634
|
const op = parse(request, parts.length)
|
|
446
635
|
const info = { agent: input.agent ?? output.message.agent, model: input.model ?? output.message.model, variant: input.variant }
|
|
636
|
+
const placeholder = { id: output.message.id, part: text }
|
|
447
637
|
|
|
448
638
|
if (control(op)) {
|
|
449
|
-
|
|
450
|
-
await toast(await manage(sid, op), "info", 5000)
|
|
639
|
+
await toast(await afterEnqueue(sid, () => manage(sid, op, placeholder)), "info", 5000)
|
|
451
640
|
return
|
|
452
641
|
}
|
|
453
642
|
|
|
454
643
|
if (op.kind === "invalid") {
|
|
455
|
-
|
|
644
|
+
await persist(sid, placeholder, () => undefined)
|
|
456
645
|
await toast(op.message, "error", 5000)
|
|
457
646
|
return
|
|
458
647
|
}
|
|
459
648
|
|
|
460
|
-
if (
|
|
649
|
+
if (!shouldQueue(current)) {
|
|
461
650
|
if (op.kind === "command") return
|
|
462
651
|
if (op.kind === "compact") {
|
|
463
|
-
|
|
652
|
+
await persist(sid, placeholder, () => undefined)
|
|
464
653
|
await compact(sid, info)
|
|
465
654
|
return
|
|
466
655
|
}
|
|
467
656
|
if (op.kind === "shell") {
|
|
468
|
-
|
|
657
|
+
await persist(sid, placeholder, () => undefined)
|
|
469
658
|
await shell(sid, op.shell, info)
|
|
470
659
|
return
|
|
471
660
|
}
|
|
@@ -473,37 +662,54 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
473
662
|
return
|
|
474
663
|
}
|
|
475
664
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
665
|
+
return orderedEnqueue(sid, async () => {
|
|
666
|
+
if (deleted.has(sid)) {
|
|
667
|
+
console.warn("QueuePlugin stopped queueing input for a deleted session", sid)
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const prior = await latest(sid)
|
|
671
|
+
if (deleted.has(sid)) {
|
|
672
|
+
console.warn("QueuePlugin stopped queueing input for a deleted session", sid)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
if (prior) Object.assign(output.message, opts(prior))
|
|
676
|
+
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
|
|
677
|
+
let item: Item
|
|
678
|
+
if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
|
|
679
|
+
else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
|
|
680
|
+
else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
|
|
681
|
+
else {
|
|
682
|
+
item = {
|
|
683
|
+
kind: "prompt",
|
|
684
|
+
info,
|
|
685
|
+
body: op.body,
|
|
686
|
+
parts: output.parts.flatMap((part): InputPart[] => {
|
|
687
|
+
if (part.type === "text") return part.id === text.id ? (request.body ? [{ ...part, text: request.body }] : []) : [{ ...part }]
|
|
688
|
+
if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
|
|
689
|
+
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
690
|
+
return []
|
|
691
|
+
}),
|
|
692
|
+
}
|
|
495
693
|
}
|
|
496
|
-
}
|
|
497
694
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
695
|
+
await persist(sid, placeholder, (draft) => {
|
|
696
|
+
if (op.front) draft.items.unshift(item)
|
|
697
|
+
else draft.items.push(item)
|
|
698
|
+
})
|
|
699
|
+
advance(sid)
|
|
700
|
+
await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
|
|
701
|
+
})
|
|
502
702
|
},
|
|
503
703
|
"experimental.chat.messages.transform": async (_, output) => {
|
|
504
|
-
output.messages = output.messages.filter((msg) =>
|
|
704
|
+
output.messages = output.messages.filter((msg) => {
|
|
705
|
+
for (const current of sessions.values()) if (current.hidden.has(msg.info.id)) return false
|
|
706
|
+
return true
|
|
707
|
+
})
|
|
505
708
|
},
|
|
506
709
|
}
|
|
710
|
+
|
|
711
|
+
for (const [sid, current] of sessions) if (current.items.length && !current.stopped) setTimeout(() => advance(sid), 0)
|
|
712
|
+
return hooks
|
|
507
713
|
}
|
|
508
714
|
|
|
509
715
|
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.1",
|
|
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": {
|