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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/index.ts +331 -125
  3. 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 abort.
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
- The queue is in-memory and scoped to the current session.
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; label: string; body: string; parts: InputPart[] }
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"; label: string; body: string }
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", label: brief(input.body, files), body: input.body, front }
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) => (item.kind === "prompt" ? item.body.trim() || item.label : item.source)
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
- export const QueuePlugin: Plugin = async ({ client, directory }) => {
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 hidden = new Set<string>()
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 = (current: State, indices: number[]) => {
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
- const settle = (sid: string, resume: boolean) => {
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 === "busy") settle(sid, true)
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
- const current = state(sid)
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
- current.activity = { kind: "sending", idle: false }
296
- let retry: Item[] = []
297
- try {
298
- const results = await Promise.all(
299
- items.map(async (item) => {
300
- try {
301
- await replay(sid, item)
302
- return { item, failed: false }
303
- } catch (error) {
304
- console.error("QueuePlugin failed to flush queued input", error)
305
- await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
306
- return { item, failed: true }
307
- }
308
- }),
309
- )
310
- retry = results.flatMap((result) => (result.failed ? [result.item] : []))
311
- } finally {
312
- if (retry.length) current.items.unshift(...retry)
313
- const failed = retry.length
314
- const replayCompleted = current.activity.kind === "sending" && current.activity.idle
315
- if (replayCompleted) settle(sid, count === 1 && failed === 0)
316
- else current.activity = failed ? { kind: "idle" } : { kind: "busy" }
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
- const current = state(sid)
323
-
324
- switch (op.kind) {
325
- case "list": {
326
- const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
327
- return current.stopped ? `${list}\nQueue is stopped` : list
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
- const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
344
- return result.failed ? `${message}; ${result.failed} failed` : message
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
- return {
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
- hide(output.message.id, text)
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
- hide(output.message.id, text)
644
+ await persist(sid, placeholder, () => undefined)
456
645
  await toast(op.message, "error", 5000)
457
646
  return
458
647
  }
459
648
 
460
- if (current.activity.kind === "idle" && !current.stopped) {
649
+ if (!shouldQueue(current)) {
461
650
  if (op.kind === "command") return
462
651
  if (op.kind === "compact") {
463
- hide(output.message.id, text)
652
+ await persist(sid, placeholder, () => undefined)
464
653
  await compact(sid, info)
465
654
  return
466
655
  }
467
656
  if (op.kind === "shell") {
468
- hide(output.message.id, text)
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
- const prior = await latest(sid)
477
- if (prior) Object.assign(output.message, opts(prior))
478
- else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
479
- let item: Item
480
- if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
481
- else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
482
- else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
483
- else {
484
- item = {
485
- kind: "prompt",
486
- info,
487
- label: op.label,
488
- body: op.body,
489
- parts: output.parts.flatMap((part): InputPart[] => {
490
- if (part.type === "text") return part.id === text.id ? (request.body ? [{ ...part, text: request.body }] : []) : [{ ...part }]
491
- if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
492
- console.warn("QueuePlugin skipped unexpected part", part.type)
493
- return []
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
- if (op.front) current.items.unshift(item)
499
- else current.items.push(item)
500
- hide(output.message.id, text)
501
- await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
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) => !hidden.has(msg.info.id))
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.11.2",
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": {