opencode-queue 0.12.2 → 0.13.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 +12 -17
  2. package/index.ts +133 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Queue OpenCode input until the current session is idle.
8
8
 
9
- `opencode-queue` adds a real `/queue` slash command. It lets you type the next prompt, slash command, or shell command while an agent is still working, without interrupting the current run.
9
+ `opencode-queue` adds `/queue` and its shorter `/q` alias. It lets you type the next prompt, slash command, or shell command while an agent is still working, without interrupting the current run.
10
10
 
11
11
  ## Install
12
12
 
@@ -23,10 +23,11 @@ Restart OpenCode after installing. OpenCode installs npm plugins automatically a
23
23
  ## Quick Examples
24
24
 
25
25
  ```text
26
- /queue continue after this task
26
+ /q continue after this task
27
27
  continue after this task /queue
28
28
  /queue front do this next
29
29
  do this next /queue front
30
+ /queue now send this immediately
30
31
 
31
32
  /queue /review
32
33
  /review /queue
@@ -50,12 +51,15 @@ do this next /queue front
50
51
 
51
52
  ## Syntax
52
53
 
54
+ `/q` is accepted anywhere `/queue` is shown.
55
+
53
56
  | Input | What it does |
54
57
  | --- | --- |
55
58
  | `/queue message` | Queue a normal prompt. |
56
59
  | `message /queue` | Queue a normal prompt using trailing syntax. |
57
60
  | `/queue front message` | Queue a normal prompt before existing queued entries. |
58
61
  | `message /queue front` | Queue a normal prompt before existing queued entries using trailing syntax. |
62
+ | `/queue now input` | Send a prompt or slash command immediately. Shell commands still wait until the session is idle. |
59
63
  | `/queue /review` | Queue a slash command. |
60
64
  | `/review /queue` | Queue a slash command using trailing syntax. |
61
65
  | `/queue front /review` | Queue a slash command before existing queued entries. |
@@ -68,6 +72,9 @@ do this next /queue front
68
72
  | `/queue list` | Show the current queue. |
69
73
  | `/queue stop` | Pause automatic sending of queued entries. |
70
74
  | `/queue start` | Resume automatic sending of queued entries. |
75
+ | `/queue always` | Show whether automatic queueing is enabled globally. |
76
+ | `/queue always on` | Enable automatic queueing in every project. |
77
+ | `/queue always off` | Disable automatic queueing in every project. |
71
78
  | `/queue flush` | Send all queued entries immediately. |
72
79
  | `/queue clear` | Clear the current queue. |
73
80
  | `/queue clear 1` | Clear item 1 from the current queue. |
@@ -82,9 +89,11 @@ When the session is busy:
82
89
  - Each queued entry replays with the agent, model, and thinking variant selected when it was queued.
83
90
  - Queued entries replay in order after the session completes normally and becomes idle.
84
91
  - `/queue front ...` puts an entry before the existing queued entries.
92
+ - `/queue now ...` sends prompts and slash commands immediately regardless of queue state or mode. Shell commands remain queued until the session is idle.
85
93
  - Only one queued entry is sent per idle transition, so queued work runs one item at a time.
86
94
  - Queued entries are kept in place after an error, abort, crash, or restart.
87
95
  - `/queue stop` pauses automatic replay without clearing queued entries, and `/queue start` resumes it.
96
+ - `/queue always on` also queues plain prompts and custom slash commands while the session is busy, paused, or already has queued work. OpenCode does not expose native shell or `/compact` submissions to these plugin hooks.
88
97
  - `/queue flush` sends all queued entries immediately in one batch, even before the session is idle.
89
98
 
90
99
  When the session is idle:
@@ -100,24 +109,10 @@ When the session is idle:
100
109
  - `/queue flush` sends all queued entries immediately in one batch.
101
110
  - `/queue clear` clears the current queue, and `/queue clear 1` clears a specific queued item.
102
111
 
103
- ## Queue Management
104
-
105
- ```text
106
- /queue
107
- /queue list
108
- /queue stop
109
- /queue start
110
- /queue flush
111
- /queue clear
112
- /queue clear 1
113
- /queue clear 2 3
114
- ```
115
-
116
- Queues are scoped to the current project and session. They are stored in OpenCode's user data directory and restored with their previous running or stopped state after OpenCode restarts or crashes. Restored queues do not replay just because the session starts idle; a running queue resumes after the session becomes busy and then finishes successfully. A send interrupted by a crash remains queued because the plugin cannot know whether OpenCode accepted it before exiting.
112
+ Queues are scoped to the current project and session. They are stored in OpenCode's user data directory and restored with their previous running or stopped state after OpenCode restarts or crashes. The `always` setting applies to every OpenCode project. Restored queues do not replay just because the session starts idle; a running queue resumes after the session becomes busy and then finishes successfully. A send interrupted by a crash remains queued because the plugin cannot know whether OpenCode accepted it before exiting.
117
113
 
118
114
  ## Notes
119
115
 
120
- - This plugin registers `/queue` as a real OpenCode slash command.
121
116
  - It does not add a keyboard shortcut. OpenCode plugins cannot currently register custom TUI keybindings.
122
117
  - Queued placeholders are hidden instead of deleted, then filtered out before messages are sent to the model.
123
118
  - If plan mode asks to switch to the build agent while more queued work is waiting, the plugin answers `No` so the queue can continue.
package/index.ts CHANGED
@@ -2,15 +2,15 @@ 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
4
  import { createHash, randomUUID } from "node:crypto"
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
5
+ import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
6
6
  import { homedir } from "node:os"
7
7
  import { dirname, join } from "node:path"
8
8
 
9
- const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
10
- const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
9
+ const SUFFIX = /^(?:([\s\S]*?)\s+)?\/(\S+)(?:\s+(front))?\s*$/
11
10
  const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
12
11
  const ITEM_NUMBER = /^[1-9]\d*$/
13
12
  const TUI_COMPACT = "session_compact"
13
+ const INTERNAL = "opencodeQueueInternal"
14
14
 
15
15
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
16
16
  type Model = { providerID: string; modelID: string }
@@ -19,7 +19,7 @@ type Info = { agent: string; model: Model; variant?: string }
19
19
  type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string; variant?: string } }
20
20
  type Ask = { type: string; properties: { id: string; sessionID: string; questions: { question: string; header: string }[] } }
21
21
  type Post = (input: { url: string; path?: Record<string, string>; body?: unknown; headers?: Record<string, string> }) => Promise<{ response?: Response; error?: unknown } | undefined>
22
- type QueueInput = { body: string; front: boolean }
22
+ type QueueInput = { body: string; modifier?: "front" | "now" }
23
23
 
24
24
  type Item =
25
25
  | { kind: "prompt"; info: Info; body: string; parts: InputPart[] }
@@ -33,32 +33,36 @@ type EntryOp =
33
33
  | { kind: "compact"; source: string }
34
34
  | { kind: "shell"; source: string; shell: string }
35
35
 
36
+ type ControlOp =
37
+ | { kind: "list" }
38
+ | { kind: "clear"; indices: number[] }
39
+ | { kind: "flush" }
40
+ | { kind: "start" }
41
+ | { kind: "stop" }
42
+ | { kind: "always"; enabled?: boolean }
43
+
36
44
  type Activity = { kind: "idle" } | { kind: "restored" } | { kind: "busy" } | { kind: "sending"; idle: boolean; items: Item[] }
37
45
  type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean; hidden: Set<string> }
38
- type Durable = Pick<State, "items" | "stopped" | "hidden">
46
+ type Draft = Pick<State, "items" | "stopped" | "hidden">
39
47
  type Store = { version: 1; projectID: string; sessions: Record<string, { items: Item[]; stopped: boolean; hidden: string[] }> }
40
48
  type Placeholder = { id: string; part: TextPart }
41
49
 
42
50
  type Op =
43
- | { kind: "list" }
44
- | { kind: "clear"; indices: number[] }
45
- | { kind: "flush" }
46
- | { kind: "start" }
47
- | { kind: "stop" }
51
+ | ControlOp
48
52
  | { kind: "invalid"; message: string }
49
53
  | (EntryOp & { front: boolean })
50
54
 
51
- type ControlOp = Extract<Op, { kind: "list" | "clear" | "flush" | "start" | "stop" }>
55
+ const isQueue = (command: string) => command === "q" || command === "queue"
52
56
 
53
57
  const parsePrefix = (body: string): QueueInput => {
54
- const match = body.trim().match(/^front(?:\s+([\s\S]*))?$/)
55
- return match ? { body: match[1] ?? "", front: true } : { body, front: false }
58
+ const match = body.trim().match(/^(front|now)(?:\s+([\s\S]*))?$/)
59
+ return match ? { body: match[2] ?? "", modifier: match[1] === "front" ? "front" : "now" } : { body }
56
60
  }
57
61
 
58
62
  const parse = (input: QueueInput, files = 0): Op => {
59
63
  const text = input.body.trim()
60
- const front = input.front
61
- if (!front && !files) {
64
+ const front = input.modifier === "front"
65
+ if (!input.modifier && !files) {
62
66
  switch (text) {
63
67
  case "":
64
68
  case "list":
@@ -71,6 +75,10 @@ const parse = (input: QueueInput, files = 0): Op => {
71
75
  return { kind: "stop" }
72
76
  }
73
77
 
78
+ if (text === "always") return { kind: "always" }
79
+ if (text === "always on" || text === "always off") return { kind: "always", enabled: text === "always on" }
80
+ if (/^always(?:\s|$)/.test(text)) return { kind: "invalid", message: "Queue always expects on or off" }
81
+
74
82
  const clear = text.match(/^clear(?:\s+([\s\S]+))?$/)
75
83
  if (clear) {
76
84
  const values = clear[1]?.trim().split(/\s+/) ?? []
@@ -79,7 +87,7 @@ const parse = (input: QueueInput, files = 0): Op => {
79
87
  return { kind: "clear", indices }
80
88
  }
81
89
  }
82
- if (front && !text && !files) return { kind: "invalid", message: "Queue front input is empty" }
90
+ if (input.modifier && !text && !files) return { kind: "invalid", message: `Queue ${input.modifier} input is empty` }
83
91
 
84
92
  if (text.startsWith("!")) {
85
93
  const shell = text.slice(1).trim()
@@ -103,17 +111,13 @@ const parse = (input: QueueInput, files = 0): Op => {
103
111
  }
104
112
 
105
113
  const parseSuffix = (text: string): QueueInput | undefined => {
106
- const trimmed = text.trim()
107
- if (trimmed === "/queue") return { body: "", front: false }
108
- if (trimmed === "/queue front") return { body: "", front: true }
109
-
110
114
  const match = text.match(SUFFIX)
111
- return match ? { body: match[1], front: match[2] === "front" } : undefined
115
+ return match && isQueue(match[2]) ? { body: match[1] ?? "", modifier: match[3] ? "front" : undefined } : undefined
112
116
  }
113
117
  const stripSuffix = (text: string) => parseSuffix(text)?.body ?? text
114
118
  const parseInput = (text: string): QueueInput | undefined => {
115
- const prefix = text.match(QUEUE)
116
- return prefix ? parsePrefix(prefix[1] ?? "") : parseSuffix(text)
119
+ const prefix = text.match(CMD)
120
+ return prefix && isQueue(prefix[1]) ? parsePrefix(prefix[2] ?? "") : parseSuffix(text)
117
121
  }
118
122
  const control = (op: Op): op is ControlOp => {
119
123
  switch (op.kind) {
@@ -122,6 +126,7 @@ const control = (op: Op): op is ControlOp => {
122
126
  case "flush":
123
127
  case "start":
124
128
  case "stop":
129
+ case "always":
125
130
  return true
126
131
  default:
127
132
  return false
@@ -192,14 +197,41 @@ const dataHome = () => {
192
197
  return join(homedir(), ".local", "share")
193
198
  }
194
199
 
200
+ const writeJson = async (path: string, value: unknown, commit = rename) => {
201
+ await mkdir(dirname(path), { recursive: true })
202
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
203
+ try {
204
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
205
+ await commit(temporary, path)
206
+ } finally {
207
+ await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary storage", error))
208
+ }
209
+ }
210
+
195
211
  export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
196
212
  const sessions = new Map<string, State>()
197
213
  const deleted = new Set<string>()
198
214
  const enqueueTurns = new Map<string, Promise<unknown>>()
215
+ const internalCommands: { sid: string; command: string; args: string; used: boolean }[] = []
216
+ const origin = randomUUID()
199
217
  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`)
218
+ const root = join(dataHome(), "opencode", "opencode-queue")
219
+ const path = join(root, `${createHash("sha256").update(project.id).digest("hex")}.json`)
220
+ const settingsPath = join(root, "settings.json")
201
221
  let writes = Promise.resolve()
202
222
 
223
+ const readAlways = async () => {
224
+ try {
225
+ const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"))
226
+ if (record(parsed) && typeof parsed.always === "boolean") return parsed.always
227
+ console.warn("QueuePlugin ignored invalid global settings", settingsPath)
228
+ } catch (error) {
229
+ if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load global settings", error)
230
+ }
231
+ return false
232
+ }
233
+ let legacyAlways = false
234
+
203
235
  try {
204
236
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"))
205
237
  if (!record(parsed) || parsed.version !== 1 || parsed.projectID !== project.id || !record(parsed.sessions)) {
@@ -218,12 +250,19 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
218
250
  const activity: Activity = { kind: items.length && !value.stopped ? "restored" : "idle" }
219
251
  if (items.length || value.stopped || hidden.size) sessions.set(sid, { items, activity, stopped: value.stopped, failed: false, hidden })
220
252
  }
253
+ legacyAlways = parsed.always === true
221
254
  }
222
255
  } catch (error) {
223
256
  if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load queue storage", error)
224
257
  }
225
-
226
- const save = async (sid?: string, draft?: Durable) => {
258
+ if (legacyAlways) {
259
+ try {
260
+ await writeJson(settingsPath, { always: true }, link)
261
+ } catch (error) {
262
+ if (!record(error) || error.code !== "EEXIST") throw error
263
+ }
264
+ }
265
+ const save = async (sid?: string, draft?: Draft) => {
227
266
  const stored: Store = { version: 1, projectID: project.id, sessions: {} }
228
267
  for (const [id, current] of sessions) {
229
268
  if (deleted.has(id) || (id === sid && !draft)) continue
@@ -231,15 +270,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
231
270
  const items = current.activity.kind === "sending" ? [...current.activity.items, ...durable.items] : durable.items
232
271
  if (items.length || durable.stopped || durable.hidden.size) stored.sessions[id] = { items, stopped: durable.stopped, hidden: [...durable.hidden] }
233
272
  }
234
- const contents = `${JSON.stringify(stored, null, 2)}\n`
235
- await mkdir(dirname(path), { recursive: true })
236
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
237
- try {
238
- await writeFile(temporary, contents, { mode: 0o600 })
239
- await rename(temporary, path)
240
- } finally {
241
- await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary queue storage", error))
242
- }
273
+ await writeJson(path, stored)
243
274
  }
244
275
 
245
276
  const serialize = <T>(action: () => Promise<T>) => {
@@ -257,7 +288,9 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
257
288
  return current
258
289
  }
259
290
 
260
- const store = async (sid: string, current: State, draft: Durable, placeholder?: Placeholder) => {
291
+ const automaticallyQueue = async (sid: string) => shouldQueue(sessions.get(sid)) && (await readAlways())
292
+
293
+ const store = async (sid: string, current: State, draft: Draft, placeholder?: Placeholder) => {
261
294
  try {
262
295
  await save(sid, draft)
263
296
  } catch (error) {
@@ -270,11 +303,11 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
270
303
  if (placeholder) Object.assign(placeholder.part, { text: "", synthetic: true, ignored: true })
271
304
  }
272
305
 
273
- const persist = <T>(sid: string, placeholder: Placeholder | undefined, mutate: (draft: Durable) => T) =>
306
+ const persist = <T>(sid: string, placeholder: Placeholder | undefined, mutate: (draft: Draft) => T) =>
274
307
  serialize(async () => {
275
308
  if (deleted.has(sid)) throw new Error(`QueuePlugin cannot persist queue state for deleted session ${sid}`)
276
309
  const current = state(sid)
277
- const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden) }
310
+ const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden) }
278
311
  if (placeholder) draft.hidden.add(placeholder.id)
279
312
  const value = mutate(draft)
280
313
  await store(sid, current, draft, placeholder)
@@ -364,6 +397,21 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
364
397
  throwOnError: true,
365
398
  })
366
399
 
400
+ const markInternal = (parts: { type: string; metadata?: Record<string, unknown> }[]) => {
401
+ const text = parts.find((part) => part.type === "text")
402
+ if (text) text.metadata = { ...text.metadata, [INTERNAL]: origin }
403
+ }
404
+
405
+ const callCommand = async <T>(sid: string, command: string, args: string, call: () => Promise<T>) => {
406
+ const pending = { sid, command, args, used: false }
407
+ internalCommands.push(pending)
408
+ try {
409
+ return await call()
410
+ } finally {
411
+ internalCommands.splice(internalCommands.indexOf(pending), 1)
412
+ }
413
+ }
414
+
367
415
  const replay = async (sid: string, item: Item) => {
368
416
  switch (item.kind) {
369
417
  case "shell":
@@ -371,19 +419,22 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
371
419
  case "compact":
372
420
  return compact(sid, item.info)
373
421
  case "command":
374
- return client.session.command({
375
- path: { id: sid },
376
- body: {
377
- ...opts(item.info),
378
- model: `${item.info.model.providerID}/${item.info.model.modelID}`,
379
- command: item.cmd,
380
- arguments: item.args,
381
- parts: item.files,
382
- } as any,
383
- throwOnError: true,
384
- })
422
+ return callCommand(sid, item.cmd, item.args, () =>
423
+ client.session.command({
424
+ path: { id: sid },
425
+ body: {
426
+ ...opts(item.info),
427
+ model: `${item.info.model.providerID}/${item.info.model.modelID}`,
428
+ command: item.cmd,
429
+ arguments: item.args,
430
+ parts: item.files,
431
+ } as any,
432
+ throwOnError: true,
433
+ }),
434
+ )
385
435
  case "prompt": {
386
436
  const parts = item.parts.map((part) => ({ ...part, id: undefined }))
437
+ markInternal(parts)
387
438
  return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any, throwOnError: true })
388
439
  }
389
440
  }
@@ -420,7 +471,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
420
471
 
421
472
  const items = current.items.slice(0, count)
422
473
  if (placeholder) {
423
- const draft: Durable = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id) }
474
+ const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id) }
424
475
  await store(sid, current, draft, placeholder)
425
476
  if (deleted.has(sid)) return undefined
426
477
  }
@@ -488,6 +539,16 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
488
539
  return result.failed ? `${message}; ${result.failed} failed` : message
489
540
  }
490
541
 
542
+ if (op.kind === "always") {
543
+ const enabled = await serialize(async () => {
544
+ if (op.enabled === undefined) return readAlways()
545
+ await writeJson(settingsPath, { always: op.enabled })
546
+ return op.enabled
547
+ })
548
+ if (placeholder) await persist(sid, placeholder, () => undefined)
549
+ return `Always queue is ${enabled ? "on" : "off"} globally`
550
+ }
551
+
491
552
  const message = await persist(sid, placeholder, (draft) => {
492
553
  switch (op.kind) {
493
554
  case "list": {
@@ -514,7 +575,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
514
575
  const hooks: Awaited<ReturnType<Plugin>> = {
515
576
  config: async (cfg) => {
516
577
  cfg.command ??= {}
517
- cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
578
+ cfg.command.q = cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
518
579
  },
519
580
  event: async ({ event }) => {
520
581
  if (plan(event)) {
@@ -576,8 +637,15 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
576
637
  const body = input.arguments ?? ""
577
638
  const parts = files(output.parts)
578
639
 
579
- if (input.command !== "queue") {
580
- const queued = parseSuffix(body)
640
+ const internal = internalCommands.find((pending) => !pending.used && pending.sid === sid && pending.command === input.command && pending.args === body)
641
+ if (internal) {
642
+ internal.used = true
643
+ markInternal(output.parts)
644
+ return
645
+ }
646
+
647
+ if (!isQueue(input.command)) {
648
+ const queued = parseSuffix(body) ?? ((await automaticallyQueue(sid)) ? { body } : undefined)
581
649
  if (!queued) return
582
650
 
583
651
  if (!shouldQueue(sessions.get(sid))) {
@@ -585,16 +653,17 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
585
653
  return
586
654
  }
587
655
 
588
- output.parts.splice(0, output.parts.length, { type: "text", text: `/queue${queued.front ? " front" : ""} /${input.command}${queued.body.trim() ? ` ${queued.body.trim()}` : ""}` } as any, ...parts)
656
+ output.parts.splice(0, output.parts.length, { type: "text", text: `/queue${queued.modifier === "front" ? " front" : ""} /${input.command}${queued.body.trim() ? ` ${queued.body.trim()}` : ""}` } as any, ...parts)
589
657
  return
590
658
  }
591
659
 
592
- const op = parse(parsePrefix(body), parts.length)
660
+ const request = parsePrefix(body)
661
+ const op = parse(request, parts.length)
593
662
 
594
663
  if (control(op)) return stop(await afterEnqueue(sid, () => manage(sid, op)))
595
664
  if (op.kind === "invalid") return stop(op.message, "error")
596
665
 
597
- if (!shouldQueue(sessions.get(sid))) {
666
+ if (!shouldQueue(sessions.get(sid)) || (request.modifier === "now" && op.kind !== "prompt" && op.kind !== "shell")) {
598
667
  if (op.kind === "shell") {
599
668
  await shell(sid, op.shell, await run(sid))
600
669
  return handled()
@@ -606,7 +675,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
606
675
  }
607
676
 
608
677
  if (op.kind === "command") {
609
- await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
678
+ await callCommand(sid, op.cmd, op.args, () => client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any }))
610
679
  return handled()
611
680
  }
612
681
 
@@ -622,10 +691,15 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
622
691
  console.warn("QueuePlugin ignored input for a deleted session", sid)
623
692
  return
624
693
  }
694
+ const internal = output.parts.find((part): part is TextPart => part.type === "text" && part.metadata?.[INTERNAL] === origin)
695
+ if (internal) {
696
+ delete internal.metadata![INTERNAL]
697
+ return
698
+ }
625
699
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
626
700
  if (!text) return
627
701
 
628
- const request = parseInput(text.text)
702
+ const request = parseInput(text.text) ?? ((await automaticallyQueue(sid)) ? { body: text.text } : undefined)
629
703
  if (!request) return
630
704
 
631
705
  const current = state(sid)
@@ -645,7 +719,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
645
719
  return
646
720
  }
647
721
 
648
- if (!shouldQueue(current)) {
722
+ if ((request.modifier === "now" && op.kind !== "shell") || !shouldQueue(current)) {
649
723
  if (op.kind === "command") return
650
724
  if (op.kind === "compact") {
651
725
  await persist(sid, placeholder, () => undefined)
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.12.2",
4
+ "version": "0.13.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",