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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/index.ts +323 -116
  3. 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 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
- - `/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
- 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.
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,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
- 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
+ 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 = (current: State, indices: number[]) => {
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
- 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)
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 === "busy") settle(sid, true)
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
- const current = state(sid)
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
- let failed = 0
296
- const retry: Item[] = []
297
- try {
298
- for (const item of items) {
299
- current.activity = { kind: "sending", idle: false }
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
- } finally {
469
+
470
+ const failed = current.failed
310
471
  if (retry.length) current.items.unshift(...retry)
311
- const replayCompleted = current.activity.kind === "sending" && current.activity.idle
312
- if (replayCompleted) settle(sid, count === 1 && failed === 0)
313
- else current.activity = failed ? { kind: "idle" } : { kind: "busy" }
314
- }
315
- return { sent: items.length - failed, failed }
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
- const current = state(sid)
320
-
321
- switch (op.kind) {
322
- case "list": {
323
- const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
324
- return current.stopped ? `${list}\nQueue is stopped` : list
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
- const message = `Flushed ${result.sent} queued item${result.sent === 1 ? "" : "s"}`
341
- return result.failed ? `${message}; ${result.failed} failed` : message
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
- return {
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
- hide(output.message.id, text)
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
- hide(output.message.id, text)
642
+ await persist(sid, placeholder, () => undefined)
453
643
  await toast(op.message, "error", 5000)
454
644
  return
455
645
  }
456
646
 
457
- if (current.activity.kind === "idle" && !current.stopped) {
647
+ if (!shouldQueue(current)) {
458
648
  if (op.kind === "command") return
459
649
  if (op.kind === "compact") {
460
- hide(output.message.id, text)
650
+ await persist(sid, placeholder, () => undefined)
461
651
  await compact(sid, info)
462
652
  return
463
653
  }
464
654
  if (op.kind === "shell") {
465
- hide(output.message.id, text)
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
- const prior = await latest(sid)
474
- if (prior) Object.assign(output.message, opts(prior))
475
- else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
476
- let item: Item
477
- if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
478
- else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
479
- else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
480
- else {
481
- item = {
482
- kind: "prompt",
483
- info,
484
- label: op.label,
485
- body: op.body,
486
- parts: output.parts.flatMap((part): InputPart[] => {
487
- if (part.type === "text") return part.id === text.id ? (request.body ? [{ ...part, text: request.body }] : []) : [{ ...part }]
488
- if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
489
- console.warn("QueuePlugin skipped unexpected part", part.type)
490
- return []
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
- if (op.front) current.items.unshift(item)
496
- else current.items.push(item)
497
- hide(output.message.id, text)
498
- await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
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) => !hidden.has(msg.info.id))
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.11.1",
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": {