opencode-queue 0.11.1 → 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 +4 -4
- package/index.ts +323 -116
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -83,9 +83,9 @@ 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
|
-
- `/queue flush` sends all queued entries immediately, even before the session is idle.
|
|
88
|
+
- `/queue flush` sends all queued entries immediately in one batch, even before the session is idle.
|
|
89
89
|
|
|
90
90
|
When the session is idle:
|
|
91
91
|
|
|
@@ -97,7 +97,7 @@ When the session is idle:
|
|
|
97
97
|
- `/queue !ls` runs `ls` immediately as an OpenCode shell block.
|
|
98
98
|
- `/queue` and `/queue list` show the current queue.
|
|
99
99
|
- `/queue stop` pauses automatic replay, and `/queue start` resumes it.
|
|
100
|
-
- `/queue flush` sends all queued entries immediately.
|
|
100
|
+
- `/queue flush` sends all queued entries immediately in one batch.
|
|
101
101
|
- `/queue clear` clears the current queue, and `/queue clear 1` clears a specific queued item.
|
|
102
102
|
|
|
103
103
|
## Queue Management
|
|
@@ -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,66 +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
|
-
|
|
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
|
|
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) => {
|
|
300
444
|
try {
|
|
301
445
|
await replay(sid, item)
|
|
446
|
+
return { item, failed: false }
|
|
302
447
|
} catch (error) {
|
|
303
|
-
failed++
|
|
304
|
-
retry.push(item)
|
|
305
448
|
console.error("QueuePlugin failed to flush queued input", error)
|
|
306
449
|
await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
450
|
+
return { item, failed: true }
|
|
307
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
|
|
308
468
|
}
|
|
309
|
-
|
|
469
|
+
|
|
470
|
+
const failed = current.failed
|
|
310
471
|
if (retry.length) current.items.unshift(...retry)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
else current.activity =
|
|
314
|
-
|
|
315
|
-
|
|
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)
|
|
478
|
+
return { sent: items.length - retry.length, failed: retry.length }
|
|
316
479
|
}
|
|
317
480
|
|
|
318
|
-
const manage = async (sid: string, op: ControlOp) => {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
case "clear":
|
|
327
|
-
return clear(current, op.indices)
|
|
328
|
-
case "stop":
|
|
329
|
-
current.stopped = true
|
|
330
|
-
return "Queue stopped"
|
|
331
|
-
case "start":
|
|
332
|
-
current.stopped = false
|
|
333
|
-
current.failed = false
|
|
334
|
-
advance(sid)
|
|
335
|
-
return "Queue started"
|
|
336
|
-
case "flush": {
|
|
337
|
-
const result = await flush(sid)
|
|
338
|
-
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
|
+
}
|
|
339
489
|
|
|
340
|
-
|
|
341
|
-
|
|
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"
|
|
342
504
|
}
|
|
505
|
+
})
|
|
506
|
+
if (op.kind === "start") {
|
|
507
|
+
state(sid).failed = false
|
|
508
|
+
advance(sid)
|
|
343
509
|
}
|
|
510
|
+
return message
|
|
344
511
|
}
|
|
345
512
|
|
|
346
|
-
|
|
513
|
+
const hooks: Awaited<ReturnType<Plugin>> = {
|
|
347
514
|
config: async (cfg) => {
|
|
348
515
|
cfg.command ??= {}
|
|
349
516
|
cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
|
|
@@ -363,11 +530,29 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
363
530
|
console.warn("QueuePlugin could not suppress queued replay after session.error because the event has no sessionID")
|
|
364
531
|
return
|
|
365
532
|
}
|
|
533
|
+
if (deleted.has(sid)) return
|
|
366
534
|
state(sid).failed = true
|
|
367
535
|
return
|
|
368
536
|
}
|
|
369
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
|
+
|
|
370
554
|
if (event.type === "session.idle") {
|
|
555
|
+
if (deleted.has(event.properties.sessionID)) return
|
|
371
556
|
idle(event.properties.sessionID)
|
|
372
557
|
return
|
|
373
558
|
}
|
|
@@ -375,6 +560,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
375
560
|
if (event.type !== "session.status") return
|
|
376
561
|
|
|
377
562
|
const sid = event.properties.sessionID
|
|
563
|
+
if (deleted.has(sid)) return
|
|
378
564
|
const current = state(sid)
|
|
379
565
|
if (event.properties.status.type !== "idle") {
|
|
380
566
|
if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
|
|
@@ -404,7 +590,7 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
404
590
|
|
|
405
591
|
const op = parse(parsePrefix(body), parts.length)
|
|
406
592
|
|
|
407
|
-
if (control(op)) return stop(await manage(sid, op))
|
|
593
|
+
if (control(op)) return stop(await afterEnqueue(sid, () => manage(sid, op)))
|
|
408
594
|
if (op.kind === "invalid") return stop(op.message, "error")
|
|
409
595
|
|
|
410
596
|
if (!shouldQueue(sessions.get(sid))) {
|
|
@@ -431,6 +617,10 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
431
617
|
},
|
|
432
618
|
"chat.message": async (input, output) => {
|
|
433
619
|
const sid = input.sessionID
|
|
620
|
+
if (deleted.has(sid)) {
|
|
621
|
+
console.warn("QueuePlugin ignored input for a deleted session", sid)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
434
624
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
435
625
|
if (!text) return
|
|
436
626
|
|
|
@@ -441,28 +631,28 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
441
631
|
const parts = files(output.parts)
|
|
442
632
|
const op = parse(request, parts.length)
|
|
443
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 }
|
|
444
635
|
|
|
445
636
|
if (control(op)) {
|
|
446
|
-
|
|
447
|
-
await toast(await manage(sid, op), "info", 5000)
|
|
637
|
+
await toast(await afterEnqueue(sid, () => manage(sid, op, placeholder)), "info", 5000)
|
|
448
638
|
return
|
|
449
639
|
}
|
|
450
640
|
|
|
451
641
|
if (op.kind === "invalid") {
|
|
452
|
-
|
|
642
|
+
await persist(sid, placeholder, () => undefined)
|
|
453
643
|
await toast(op.message, "error", 5000)
|
|
454
644
|
return
|
|
455
645
|
}
|
|
456
646
|
|
|
457
|
-
if (
|
|
647
|
+
if (!shouldQueue(current)) {
|
|
458
648
|
if (op.kind === "command") return
|
|
459
649
|
if (op.kind === "compact") {
|
|
460
|
-
|
|
650
|
+
await persist(sid, placeholder, () => undefined)
|
|
461
651
|
await compact(sid, info)
|
|
462
652
|
return
|
|
463
653
|
}
|
|
464
654
|
if (op.kind === "shell") {
|
|
465
|
-
|
|
655
|
+
await persist(sid, placeholder, () => undefined)
|
|
466
656
|
await shell(sid, op.shell, info)
|
|
467
657
|
return
|
|
468
658
|
}
|
|
@@ -470,37 +660,54 @@ export const QueuePlugin: Plugin = async ({ client, directory }) => {
|
|
|
470
660
|
return
|
|
471
661
|
}
|
|
472
662
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|
|
492
691
|
}
|
|
493
|
-
}
|
|
494
692
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
})
|
|
499
700
|
},
|
|
500
701
|
"experimental.chat.messages.transform": async (_, output) => {
|
|
501
|
-
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
|
+
})
|
|
502
706
|
},
|
|
503
707
|
}
|
|
708
|
+
|
|
709
|
+
for (const [sid, current] of sessions) if (current.items.length && !current.stopped) setTimeout(() => advance(sid), 0)
|
|
710
|
+
return hooks
|
|
504
711
|
}
|
|
505
712
|
|
|
506
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": {
|