opencode-queue 0.5.1 → 0.6.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.
- package/README.md +12 -6
- package/index.ts +71 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,12 +7,13 @@ 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
|
+
- Queues shell commands with `/queue !ls`
|
|
12
13
|
- Hides queued placeholders from both the UI transcript and the running agent
|
|
14
|
+
- Preserves the selected agent, model, and thinking variant for queued input
|
|
13
15
|
- Replays queued input in order once the session becomes idle
|
|
14
16
|
- 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
17
|
- Registers `/queue` as a real OpenCode slash command
|
|
17
18
|
- Shows the current queue with `/queue list`
|
|
18
19
|
- Clears the current queue with `/queue clear`
|
|
@@ -39,7 +40,9 @@ While the agent is busy:
|
|
|
39
40
|
/queue continue after the current task finishes
|
|
40
41
|
/queue /review
|
|
41
42
|
/queue /commit
|
|
42
|
-
/queue !
|
|
43
|
+
/queue !ls
|
|
44
|
+
continue after the current task finishes /queue
|
|
45
|
+
/review /queue
|
|
43
46
|
/queue list
|
|
44
47
|
/queue clear
|
|
45
48
|
```
|
|
@@ -50,6 +53,8 @@ When the session is idle:
|
|
|
50
53
|
/queue hello
|
|
51
54
|
/queue /review
|
|
52
55
|
/queue !date
|
|
56
|
+
hello /queue
|
|
57
|
+
/review /queue
|
|
53
58
|
/queue
|
|
54
59
|
```
|
|
55
60
|
|
|
@@ -59,11 +64,12 @@ Queued items stay hidden while the current run is still working, then replay aut
|
|
|
59
64
|
|
|
60
65
|
- This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
|
|
61
66
|
- Idle `/queue some text` is treated like a normal prompt with the `/queue` prefix removed.
|
|
67
|
+
- Idle `some text /queue` and `/command /queue` run immediately with the trailing `/queue` removed.
|
|
62
68
|
- Idle `/queue /command` immediately runs the nested command.
|
|
63
|
-
- Idle `/queue !command` immediately runs the shell command as
|
|
69
|
+
- Idle `/queue !command` immediately runs the shell command as an OpenCode shell block.
|
|
64
70
|
- `/queue` and `/queue list` show the in-memory queue for the current session.
|
|
65
71
|
- `/queue clear` drops all currently queued items for the current session.
|
|
66
|
-
-
|
|
72
|
+
- Native shell-mode suffixes like `!command /queue` are not supported because OpenCode handles leading `!` before plugin command hooks run.
|
|
67
73
|
|
|
68
74
|
## License
|
|
69
75
|
|
package/index.ts
CHANGED
|
@@ -2,13 +2,16 @@ 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 Meta = { variant?: string; controls?: string[]; fast?: boolean }
|
|
11
12
|
type Run = { agent: string; model?: Model }
|
|
13
|
+
type Info = { agent: string; model: Model } & Meta
|
|
14
|
+
type Msg = { info: { role: string; agent?: string; mode?: string; model?: Model; providerID?: string; modelID?: string } & Meta }
|
|
12
15
|
|
|
13
16
|
type Item =
|
|
14
17
|
| { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
|
|
@@ -18,7 +21,7 @@ type Item =
|
|
|
18
21
|
type Op =
|
|
19
22
|
| { kind: "list" }
|
|
20
23
|
| { kind: "clear" }
|
|
21
|
-
| { kind: "invalid";
|
|
24
|
+
| { kind: "invalid"; text: string }
|
|
22
25
|
| { kind: "prompt"; text: string; body: string }
|
|
23
26
|
| { kind: "command"; text: string; cmd: string; args: string }
|
|
24
27
|
| { kind: "shell"; text: string; shell: string }
|
|
@@ -37,8 +40,8 @@ const parse = (body: string, files = 0): Op => {
|
|
|
37
40
|
|
|
38
41
|
if (text.startsWith("!")) {
|
|
39
42
|
const shell = text.slice(1).trim()
|
|
40
|
-
if (!shell) return { kind: "invalid",
|
|
41
|
-
if (files) return { kind: "invalid",
|
|
43
|
+
if (!shell) return { kind: "invalid", text: "Queue shell command is empty" }
|
|
44
|
+
if (files) return { kind: "invalid", text: "Queued shell commands do not support attachments" }
|
|
42
45
|
return { kind: "shell", text, shell }
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -47,6 +50,10 @@ const parse = (body: string, files = 0): Op => {
|
|
|
47
50
|
return { kind: "prompt", text: label(body, files), body }
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
const trailing = (text: string) => (text.trim() === "/queue" ? "" : text.match(SUFFIX)?.[1])
|
|
54
|
+
const strip = (text: string) => trailing(text) ?? text
|
|
55
|
+
const queued = (text: string) => text.match(QUEUE)?.[1] ?? trailing(text)
|
|
56
|
+
|
|
50
57
|
export const QueuePlugin: Plugin = async ({ client }) => {
|
|
51
58
|
const queue = new Map<string, Item[]>()
|
|
52
59
|
const hidden = new Set<string>()
|
|
@@ -66,9 +73,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
66
73
|
Object.assign(part, { text: "", synthetic: true, ignored: true })
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const files = (parts: { type: string }[]) => parts.flatMap((part) => (part.type === "file" ? [{ ...(part as FilePart) }] : []))
|
|
76
|
+
const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
72
77
|
|
|
73
78
|
const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
|
|
74
79
|
if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
|
|
@@ -78,38 +83,43 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
78
83
|
return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
const latest = async (sid: string): Promise<
|
|
86
|
+
const latest = async (sid: string): Promise<Info | undefined> => {
|
|
82
87
|
const result = await client.session.messages({ path: { id: sid }, query: { limit: 100 } }).catch((error) => {
|
|
83
|
-
console.warn("QueuePlugin could not inspect session messages for
|
|
88
|
+
console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
|
|
84
89
|
return []
|
|
85
90
|
})
|
|
86
91
|
|
|
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"
|
|
92
|
+
for (const msg of [...(Array.isArray(result) ? result : (result.data ?? []))].reverse() as Msg[]) {
|
|
93
|
+
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 }
|
|
94
|
+
if (msg.info.role === "assistant" && (msg.info.agent || msg.info.mode) && msg.info.providerID && msg.info.modelID) {
|
|
95
|
+
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 }
|
|
96
|
+
}
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const run = async (sid: string): Promise<Run> => {
|
|
103
|
+
const info = await latest(sid)
|
|
104
|
+
if (info) return info
|
|
92
105
|
console.warn("QueuePlugin shell replay fell back to the build agent because the session has no message context")
|
|
93
106
|
return { agent: "build" }
|
|
94
107
|
}
|
|
95
108
|
|
|
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
|
-
})
|
|
109
|
+
const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
|
|
101
110
|
|
|
102
|
-
const
|
|
111
|
+
const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
|
|
112
|
+
const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
|
|
103
113
|
|
|
104
114
|
const replay = async (sid: string, item: Item) => {
|
|
105
115
|
if (item.kind === "shell") return shell(sid, item.shell, item.info)
|
|
106
116
|
|
|
107
117
|
if (item.kind === "command") {
|
|
108
|
-
await
|
|
118
|
+
await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
|
|
109
119
|
await client.session.command({
|
|
110
120
|
path: { id: sid },
|
|
111
121
|
body: {
|
|
112
|
-
|
|
122
|
+
...opts(item.info),
|
|
113
123
|
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
|
|
114
124
|
command: item.cmd,
|
|
115
125
|
arguments: item.args,
|
|
@@ -120,10 +130,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
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
|
-
})
|
|
133
|
+
return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
|
|
127
134
|
}
|
|
128
135
|
console.warn("QueuePlugin skipped queued item without replayable content")
|
|
129
136
|
}
|
|
@@ -163,48 +170,59 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
163
170
|
await flush(sid)
|
|
164
171
|
},
|
|
165
172
|
"command.execute.before": async (input, output) => {
|
|
166
|
-
if (input.command !== "queue") return
|
|
167
|
-
|
|
168
173
|
const sid = input.sessionID
|
|
169
174
|
const body = input.arguments ?? ""
|
|
170
|
-
const
|
|
171
|
-
const op = parse(body, found.length)
|
|
175
|
+
const parts = files(output.parts)
|
|
172
176
|
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
if (input.command !== "queue") {
|
|
178
|
+
const args = trailing(body)
|
|
179
|
+
if (args === undefined) return
|
|
180
|
+
|
|
181
|
+
if (!busy.has(sid)) {
|
|
182
|
+
for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
output.parts.length = 0
|
|
187
|
+
output.parts.push({ type: "text", text: `/queue /${input.command}${args.trim() ? ` ${args.trim()}` : ""}` } as any, ...parts)
|
|
188
|
+
return
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
const op = parse(body, parts.length)
|
|
192
|
+
|
|
193
|
+
if (op.kind === "list" || op.kind === "clear") return stop(manage(sid, op))
|
|
194
|
+
if (op.kind === "invalid") return stop(op.text, "error")
|
|
195
|
+
|
|
179
196
|
if (!busy.has(sid)) {
|
|
180
197
|
if (op.kind === "shell") {
|
|
181
|
-
await shell(sid, op.shell, await
|
|
198
|
+
await shell(sid, op.shell, await run(sid))
|
|
182
199
|
throw new Error(HANDLED)
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
if (op.kind === "command") {
|
|
186
|
-
await
|
|
187
|
-
await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts
|
|
203
|
+
await client.session.prompt({ path: { id: sid }, body: { noReply: true, parts: [{ type: "text", text: op.text }, ...parts] } })
|
|
204
|
+
await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
|
|
188
205
|
throw new Error(HANDLED)
|
|
189
206
|
}
|
|
190
207
|
|
|
191
208
|
output.parts.length = 0
|
|
192
|
-
output.parts.push({ type: "text", text: op.body } as any, ...
|
|
209
|
+
output.parts.push({ type: "text", text: op.body } as any, ...parts)
|
|
193
210
|
return
|
|
194
211
|
}
|
|
195
212
|
|
|
196
213
|
output.parts.length = 0
|
|
197
|
-
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...
|
|
214
|
+
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
|
|
198
215
|
},
|
|
199
|
-
"chat.message": async (
|
|
216
|
+
"chat.message": async (input, output) => {
|
|
217
|
+
const sid = input.sessionID
|
|
200
218
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
201
219
|
if (!text) return
|
|
202
220
|
|
|
203
|
-
const body = text.text
|
|
221
|
+
const body = queued(text.text)
|
|
204
222
|
if (body === undefined) return
|
|
205
223
|
|
|
206
|
-
const
|
|
207
|
-
const op = parse(body,
|
|
224
|
+
const parts = files(output.parts)
|
|
225
|
+
const op = parse(body, parts.length)
|
|
208
226
|
|
|
209
227
|
if (op.kind === "list" || op.kind === "clear") {
|
|
210
228
|
hide(output.message.id, text)
|
|
@@ -214,18 +232,26 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
214
232
|
|
|
215
233
|
if (op.kind === "invalid") {
|
|
216
234
|
hide(output.message.id, text)
|
|
217
|
-
|
|
218
|
-
await toast(op.message, "error")
|
|
235
|
+
await toast(op.text, "error", 5000)
|
|
219
236
|
return
|
|
220
237
|
}
|
|
221
238
|
|
|
222
239
|
if (!busy.has(sid)) {
|
|
223
240
|
if (op.kind === "command") return
|
|
241
|
+
if (op.kind === "shell") {
|
|
242
|
+
hide(output.message.id, text)
|
|
243
|
+
await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
224
246
|
text.text = body
|
|
225
247
|
return
|
|
226
248
|
}
|
|
227
249
|
|
|
228
|
-
const
|
|
250
|
+
const meta = input as Meta
|
|
251
|
+
const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
|
|
252
|
+
const prior = await latest(sid)
|
|
253
|
+
if (prior) Object.assign(output.message, opts(prior))
|
|
254
|
+
else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
|
|
229
255
|
const inputParts = () =>
|
|
230
256
|
output.parts.flatMap((part): InputPart[] => {
|
|
231
257
|
if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
|
|
@@ -233,7 +259,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
233
259
|
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
234
260
|
return []
|
|
235
261
|
})
|
|
236
|
-
const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files:
|
|
262
|
+
const item: Item = op.kind === "shell" ? { ...op, info } : op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
|
|
237
263
|
|
|
238
264
|
queue.set(sid, [...(queue.get(sid) ?? []), item])
|
|
239
265
|
hide(output.message.id, text)
|
package/package.json
CHANGED