opencode-queue 0.1.0 → 0.2.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 +6 -3
  2. package/index.ts +76 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,9 +8,10 @@ This plugin adds a `/queue ...` prefix that keeps the current run focused instea
8
8
 
9
9
  - Queues normal prompts entered while a session is busy
10
10
  - Queues slash commands like `/queue /review` and `/queue /commit`
11
- - Keeps queued placeholder messages visible in the UI
12
- - Filters queued placeholders out of model input so they do not interrupt the current run
11
+ - Hides queued placeholders from both the UI transcript and the running agent
13
12
  - Replays queued input in order once the session becomes idle
13
+ - Replays queued commands as a visible `/command` message before executing them
14
+ - Shows the current queue with `/queue list`
14
15
 
15
16
  ## Install
16
17
 
@@ -34,15 +35,17 @@ While the agent is busy:
34
35
  /queue continue after the current task finishes
35
36
  /queue /review
36
37
  /queue /commit
38
+ /queue list
37
39
  ```
38
40
 
39
- The queued message is shown as `[queued] ...` and is sent automatically when the current run becomes idle.
41
+ Queued items stay hidden while the current run is still working, then replay automatically when the session becomes idle.
40
42
 
41
43
  ## Notes
42
44
 
43
45
  - This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
44
46
  - Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
45
47
  - Idle `/queue /command` is left alone and is not intercepted.
48
+ - `/queue list` shows the in-memory queue for the current session.
46
49
 
47
50
  ## License
48
51
 
package/index.ts CHANGED
@@ -13,7 +13,7 @@ const COMMAND = /^\/(\S+)(?:\s+([\s\S]*))?$/
13
13
 
14
14
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
15
15
  type Info = { agent: string; model: { providerID: string; modelID: string } }
16
- type Item = { info: Info; command: string; arguments: string } | { info: Info; parts: InputPart[] }
16
+ type Item = { info: Info; text: string; parts?: InputPart[]; command?: string; arguments?: string }
17
17
 
18
18
  const label = (body: string, files: number) => {
19
19
  const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
@@ -25,14 +25,72 @@ const parseCommand = (body: string) => {
25
25
  return match ? { command: match[1], arguments: match[2] ?? "" } : undefined
26
26
  }
27
27
 
28
- const QueuePlugin: Plugin = async ({ client }) => {
28
+ export const QueuePlugin: Plugin = async ({ client }) => {
29
29
  const queue = new Map<string, Item[]>()
30
30
  const hidden = new Set<string>()
31
31
  const busy = new Set<string>()
32
32
  const flushing = new Set<string>()
33
33
 
34
- const toast = (message: string, variant: "info" | "error") =>
35
- client.tui.showToast({ body: { message, variant, duration: 2500 } }).catch(() => undefined)
34
+ const toast = (message: string, variant: "info" | "error", duration = 2500) =>
35
+ client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
36
+
37
+ const hide = (id: string, text: TextPart) => {
38
+ hidden.add(id)
39
+ text.text = ""
40
+ text.synthetic = true
41
+ text.ignored = true
42
+ }
43
+
44
+ const enqueue = (sid: string, item: Item) => {
45
+ const items = queue.get(sid)
46
+ if (items) items.push(item)
47
+ else queue.set(sid, [item])
48
+ }
49
+
50
+ const summary = (sid: string) => {
51
+ const items = queue.get(sid) ?? []
52
+ if (!items.length) return "Queue is empty"
53
+ return items.map((item, i) => `${i + 1}. ${item.text}`).join("\n")
54
+ }
55
+
56
+ const replay = async (sid: string, item: Item) => {
57
+ if (!item.command || item.arguments === undefined) {
58
+ if (!item.parts?.length) {
59
+ console.warn("QueuePlugin skipped queued item without replayable content")
60
+ return
61
+ }
62
+
63
+ await client.session.prompt({
64
+ path: { id: sid },
65
+ body: {
66
+ agent: item.info.agent,
67
+ model: item.info.model,
68
+ parts: item.parts.map((part) => ({ ...part, id: undefined })),
69
+ },
70
+ })
71
+ return
72
+ }
73
+
74
+ await client.session.prompt({
75
+ path: { id: sid },
76
+ body: {
77
+ agent: item.info.agent,
78
+ model: item.info.model,
79
+ noReply: true,
80
+ parts: [{ type: "text", text: item.text }],
81
+ },
82
+ })
83
+
84
+ await client.session.command({
85
+ path: { id: sid },
86
+ body: {
87
+ agent: item.info.agent,
88
+ model: `${item.info.model.providerID}/${item.info.model.modelID}`,
89
+ command: item.command,
90
+ arguments: item.arguments,
91
+ },
92
+ })
93
+ }
36
94
 
37
95
  const flush = async (sid: string) => {
38
96
  if (flushing.has(sid)) return
@@ -46,28 +104,7 @@ const QueuePlugin: Plugin = async ({ client }) => {
46
104
  while (list.length) {
47
105
  const item = list.shift()
48
106
  if (!item) break
49
-
50
- if ("command" in item) {
51
- await client.session.command({
52
- path: { id: sid },
53
- body: {
54
- agent: item.info.agent,
55
- model: `${item.info.model.providerID}/${item.info.model.modelID}`,
56
- command: item.command,
57
- arguments: item.arguments,
58
- },
59
- })
60
- continue
61
- }
62
-
63
- await client.session.prompt({
64
- path: { id: sid },
65
- body: {
66
- agent: item.info.agent,
67
- model: item.info.model,
68
- parts: item.parts.map((part) => ({ ...part, id: undefined })),
69
- },
70
- })
107
+ await replay(sid, item)
71
108
  }
72
109
  } catch (error) {
73
110
  const message = error instanceof Error ? error.message : String(error)
@@ -101,10 +138,16 @@ const QueuePlugin: Plugin = async ({ client }) => {
101
138
  if (body === undefined) return
102
139
 
103
140
  const files = output.parts.filter((part): part is FilePart => part.type === "file")
104
- if (!body.trim() && !files.length) return
141
+ const trimmed = body.trim()
142
+
143
+ if ((!trimmed || trimmed === "list") && !files.length) {
144
+ hide(output.message.id, text)
145
+ await toast(summary(sessionID), "info", 5000)
146
+ return
147
+ }
105
148
 
106
149
  if (!busy.has(sessionID)) {
107
- if (body.trimStart().startsWith("/")) return
150
+ if (trimmed.startsWith("/")) return
108
151
  text.text = body
109
152
  return
110
153
  }
@@ -126,15 +169,11 @@ const QueuePlugin: Plugin = async ({ client }) => {
126
169
 
127
170
  const info = { agent: output.message.agent, model: { ...output.message.model } }
128
171
  const command = parseCommand(body)
129
- const item = command ? { info, ...command } : { info, parts }
130
- const list = queue.get(sessionID)
131
- if (list) list.push(item)
132
- else queue.set(sessionID, [item])
133
-
134
- const note = label(body, files.length)
135
- hidden.add(output.message.id)
136
- text.text = `[queued] ${note}`
137
- await toast(`Queued: ${note}`, "info")
172
+ const item = command ? { info, text: trimmed, ...command } : { info, text: label(body, files.length), parts }
173
+
174
+ enqueue(sessionID, item)
175
+ hide(output.message.id, text)
176
+ await toast(`Queued: ${item.text}`, "info")
138
177
  },
139
178
  "experimental.chat.messages.transform": async (_, output) => {
140
179
  output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))
@@ -142,5 +181,4 @@ const QueuePlugin: Plugin = async ({ client }) => {
142
181
  }
143
182
  }
144
183
 
145
- export { QueuePlugin }
146
184
  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.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",