opencode-queue 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/index.ts +54 -66
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@ This plugin adds a real `/queue` slash command that keeps the current run focuse
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
9
9
|
- Queues normal prompts entered while a session is busy
|
|
10
|
-
- Queues
|
|
11
|
-
- Queues
|
|
10
|
+
- Queues prompts with either `/queue prompt` or `prompt /queue`
|
|
11
|
+
- Queues slash commands with either `/queue /review` or `/review /queue`
|
|
12
12
|
- Hides queued placeholders from both the UI transcript and the running agent
|
|
13
|
+
- Preserves the selected agent, model, and thinking variant for queued input
|
|
13
14
|
- Replays queued input in order once the session becomes idle
|
|
14
15
|
- Replays queued commands as a visible `/command` message before executing them
|
|
15
|
-
- Replays queued shell commands as shell tool blocks without adding a literal `!command` user message
|
|
16
16
|
- Registers `/queue` as a real OpenCode slash command
|
|
17
17
|
- Shows the current queue with `/queue list`
|
|
18
18
|
- Clears the current queue with `/queue clear`
|
|
@@ -39,7 +39,8 @@ While the agent is busy:
|
|
|
39
39
|
/queue continue after the current task finishes
|
|
40
40
|
/queue /review
|
|
41
41
|
/queue /commit
|
|
42
|
-
/queue
|
|
42
|
+
continue after the current task finishes /queue
|
|
43
|
+
/review /queue
|
|
43
44
|
/queue list
|
|
44
45
|
/queue clear
|
|
45
46
|
```
|
|
@@ -49,7 +50,8 @@ When the session is idle:
|
|
|
49
50
|
```text
|
|
50
51
|
/queue hello
|
|
51
52
|
/queue /review
|
|
52
|
-
/queue
|
|
53
|
+
hello /queue
|
|
54
|
+
/review /queue
|
|
53
55
|
/queue
|
|
54
56
|
```
|
|
55
57
|
|
|
@@ -59,11 +61,11 @@ Queued items stay hidden while the current run is still working, then replay aut
|
|
|
59
61
|
|
|
60
62
|
- This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
|
|
61
63
|
- Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
|
|
64
|
+
- Idle `some text /queue` and `/command /queue` run immediately with the trailing `/queue` removed.
|
|
62
65
|
- Idle `/queue /command` immediately runs the nested command.
|
|
63
|
-
- Idle `/queue !command` immediately runs the shell command as a shell tool block.
|
|
64
66
|
- `/queue` and `/queue list` show the in-memory queue for the current session.
|
|
65
67
|
- `/queue clear` drops all currently queued items for the current session.
|
|
66
|
-
-
|
|
68
|
+
- OpenCode shell mode is not supported because it uses a separate shell execution path outside normal prompt and slash-command handling.
|
|
67
69
|
|
|
68
70
|
## License
|
|
69
71
|
|
package/index.ts
CHANGED
|
@@ -2,26 +2,25 @@ import type { Plugin } from "@opencode-ai/plugin"
|
|
|
2
2
|
import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
|
|
3
3
|
|
|
4
4
|
const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
|
|
5
|
+
const SUFFIX = /^([\s\S]*?)\s+\/queue\s*$/
|
|
5
6
|
const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
|
|
6
7
|
const HANDLED = "__QUEUE_HANDLED__"
|
|
7
8
|
|
|
8
9
|
type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
|
|
9
10
|
type Model = { providerID: string; modelID: string }
|
|
10
|
-
type
|
|
11
|
-
type
|
|
11
|
+
type Meta = { variant?: string; controls?: string[]; fast?: boolean }
|
|
12
|
+
type Info = { agent: string; model: Model } & Meta
|
|
13
|
+
type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
|
|
12
14
|
|
|
13
15
|
type Item =
|
|
14
16
|
| { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
|
|
15
17
|
| { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
|
|
16
|
-
| { kind: "shell"; info: Info; text: string; shell: string }
|
|
17
18
|
|
|
18
19
|
type Op =
|
|
19
20
|
| { kind: "list" }
|
|
20
21
|
| { kind: "clear" }
|
|
21
|
-
| { kind: "invalid"; message: string; warn?: string }
|
|
22
22
|
| { kind: "prompt"; text: string; body: string }
|
|
23
23
|
| { kind: "command"; text: string; cmd: string; args: string }
|
|
24
|
-
| { kind: "shell"; text: string; shell: string }
|
|
25
24
|
|
|
26
25
|
const label = (body: string, files: number) => {
|
|
27
26
|
const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
|
|
@@ -35,18 +34,15 @@ const parse = (body: string, files = 0): Op => {
|
|
|
35
34
|
if (text === "clear") return { kind: "clear" }
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
if (text.startsWith("!")) {
|
|
39
|
-
const shell = text.slice(1).trim()
|
|
40
|
-
if (!shell) return { kind: "invalid", message: "Queue shell command is empty" }
|
|
41
|
-
if (files) return { kind: "invalid", message: "Queued shell commands do not support attachments", warn: "QueuePlugin skipped shell command attachments" }
|
|
42
|
-
return { kind: "shell", text, shell }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
37
|
const match = text.match(CMD)
|
|
46
38
|
if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
|
|
47
39
|
return { kind: "prompt", text: label(body, files), body }
|
|
48
40
|
}
|
|
49
41
|
|
|
42
|
+
const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
|
|
43
|
+
const strip = (text: string) => trailing(text) ?? text
|
|
44
|
+
const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
|
|
45
|
+
|
|
50
46
|
export const QueuePlugin: Plugin = async ({ client }) => {
|
|
51
47
|
const queue = new Map<string, Item[]>()
|
|
52
48
|
const hidden = new Set<string>()
|
|
@@ -66,9 +62,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
66
62
|
Object.assign(part, { text: "", synthetic: true, ignored: true })
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const files = (parts: { type: string }[]) => parts.flatMap((part) => (part.type === "file" ? [{ ...(part as FilePart) }] : []))
|
|
65
|
+
const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
72
66
|
|
|
73
67
|
const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
|
|
74
68
|
if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
|
|
@@ -78,38 +72,33 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
78
72
|
return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
const latest = async (sid: string): Promise<
|
|
75
|
+
const latest = async (sid: string): Promise<Info | undefined> => {
|
|
82
76
|
const result = await client.session.messages({ path: { id: sid }, query: { limit: 100 } }).catch((error) => {
|
|
83
|
-
console.warn("QueuePlugin could not inspect session messages for
|
|
77
|
+
console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
|
|
84
78
|
return []
|
|
85
79
|
})
|
|
86
80
|
|
|
87
|
-
for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse()) {
|
|
88
|
-
if (msg.info.role === "user") return { agent: msg.info.agent, model: msg.info.model }
|
|
89
|
-
if (msg.info.role === "assistant"
|
|
81
|
+
for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]) {
|
|
82
|
+
if (msg.info.role === "user" && msg.info.agent && msg.info.model) return { agent: msg.info.agent, model: msg.info.model, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
|
|
83
|
+
if (msg.info.role === "assistant" && (msg.info.agent || msg.info.mode) && msg.info.providerID && msg.info.modelID) {
|
|
84
|
+
return { agent: msg.info.agent ?? msg.info.mode!, model: { providerID: msg.info.providerID, modelID: msg.info.modelID }, variant: msg.info.variant, controls: msg.info.controls, fast: msg.info.fast }
|
|
85
|
+
}
|
|
90
86
|
}
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
return { agent: "build" }
|
|
88
|
+
return undefined
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
const
|
|
97
|
-
client.session.prompt({
|
|
98
|
-
path: { id: sid },
|
|
99
|
-
body: { agent: info?.agent, model: info?.model, noReply: true, parts: [{ type: "text", text }, ...parts] },
|
|
100
|
-
})
|
|
91
|
+
const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
|
|
101
92
|
|
|
102
|
-
const
|
|
93
|
+
const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
|
|
103
94
|
|
|
104
95
|
const replay = async (sid: string, item: Item) => {
|
|
105
|
-
if (item.kind === "shell") return shell(sid, item.shell, item.info)
|
|
106
|
-
|
|
107
96
|
if (item.kind === "command") {
|
|
108
|
-
await
|
|
97
|
+
await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
|
|
109
98
|
await client.session.command({
|
|
110
99
|
path: { id: sid },
|
|
111
100
|
body: {
|
|
112
|
-
|
|
101
|
+
...opts(item.info),
|
|
113
102
|
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
|
|
114
103
|
command: item.cmd,
|
|
115
104
|
arguments: item.args,
|
|
@@ -120,10 +109,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
120
109
|
}
|
|
121
110
|
|
|
122
111
|
if (item.parts.length) {
|
|
123
|
-
return
|
|
124
|
-
path: { id: sid },
|
|
125
|
-
body: { agent: item.info.agent, model: item.info.model, parts: item.parts.map((part) => ({ ...part, id: undefined })) },
|
|
126
|
-
})
|
|
112
|
+
return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
|
|
127
113
|
}
|
|
128
114
|
console.warn("QueuePlugin skipped queued item without replayable content")
|
|
129
115
|
}
|
|
@@ -163,48 +149,53 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
163
149
|
await flush(sid)
|
|
164
150
|
},
|
|
165
151
|
"command.execute.before": async (input, output) => {
|
|
166
|
-
if (input.command !== "queue") return
|
|
167
|
-
|
|
168
152
|
const sid = input.sessionID
|
|
169
153
|
const body = input.arguments ?? ""
|
|
170
|
-
const
|
|
171
|
-
const op = parse(body, found.length)
|
|
154
|
+
const parts = files(output.parts)
|
|
172
155
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return stop(op.message, "error")
|
|
177
|
-
}
|
|
156
|
+
if (input.command !== "queue") {
|
|
157
|
+
const args = trailing(body)
|
|
158
|
+
if (args === undefined) return
|
|
178
159
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
throw new Error(HANDLED)
|
|
160
|
+
if (!busy.has(sid)) {
|
|
161
|
+
for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
|
|
162
|
+
return
|
|
183
163
|
}
|
|
184
164
|
|
|
165
|
+
output.parts.length = 0
|
|
166
|
+
output.parts.push({ type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const op = parse(body, parts.length)
|
|
171
|
+
|
|
172
|
+
if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
|
|
173
|
+
|
|
174
|
+
if (!busy.has(sid)) {
|
|
185
175
|
if (op.kind === "command") {
|
|
186
|
-
await
|
|
187
|
-
await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts
|
|
176
|
+
await client.session.prompt({ path: { id: sid }, body: { noReply: true, parts: [{ type: "text", text: op.text }, ...parts] } })
|
|
177
|
+
await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
|
|
188
178
|
throw new Error(HANDLED)
|
|
189
179
|
}
|
|
190
180
|
|
|
191
181
|
output.parts.length = 0
|
|
192
|
-
output.parts.push({ type: "text", text: op.body } as any, ...
|
|
182
|
+
output.parts.push({ type: "text", text: op.body } as any, ...parts)
|
|
193
183
|
return
|
|
194
184
|
}
|
|
195
185
|
|
|
196
186
|
output.parts.length = 0
|
|
197
|
-
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...
|
|
187
|
+
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
|
|
198
188
|
},
|
|
199
|
-
"chat.message": async (
|
|
189
|
+
"chat.message": async (input, output) => {
|
|
190
|
+
const sid = input.sessionID
|
|
200
191
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
201
192
|
if (!text) return
|
|
202
193
|
|
|
203
|
-
const body = text.text
|
|
194
|
+
const body = queued(text.text)
|
|
204
195
|
if (body === undefined) return
|
|
205
196
|
|
|
206
|
-
const
|
|
207
|
-
const op = parse(body,
|
|
197
|
+
const parts = files(output.parts)
|
|
198
|
+
const op = parse(body, parts.length)
|
|
208
199
|
|
|
209
200
|
if (op.kind === "list" || op.kind === "clear") {
|
|
210
201
|
hide(output.message.id, text)
|
|
@@ -212,20 +203,17 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
212
203
|
return
|
|
213
204
|
}
|
|
214
205
|
|
|
215
|
-
if (op.kind === "invalid") {
|
|
216
|
-
hide(output.message.id, text)
|
|
217
|
-
warn(op)
|
|
218
|
-
await toast(op.message, "error")
|
|
219
|
-
return
|
|
220
|
-
}
|
|
221
|
-
|
|
222
206
|
if (!busy.has(sid)) {
|
|
223
207
|
if (op.kind === "command") return
|
|
224
208
|
text.text = body
|
|
225
209
|
return
|
|
226
210
|
}
|
|
227
211
|
|
|
228
|
-
const
|
|
212
|
+
const meta = input as Meta
|
|
213
|
+
const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
|
|
214
|
+
const prior = await latest(sid)
|
|
215
|
+
if (prior) Object.assign(output.message, opts(prior))
|
|
216
|
+
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
|
|
229
217
|
const inputParts = () =>
|
|
230
218
|
output.parts.flatMap((part): InputPart[] => {
|
|
231
219
|
if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
|
|
@@ -233,7 +221,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
233
221
|
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
234
222
|
return []
|
|
235
223
|
})
|
|
236
|
-
const item: Item = op.kind === "
|
|
224
|
+
const item: Item = op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
|
|
237
225
|
|
|
238
226
|
queue.set(sid, [...(queue.get(sid) ?? []), item])
|
|
239
227
|
hide(output.message.id, text)
|
package/package.json
CHANGED