opencode-queue 0.4.0 → 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 -1
- package/index.ts +127 -133
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ 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
|
|
10
|
+
- Queues prompts with either `/queue prompt` or `prompt /queue`
|
|
11
|
+
- Queues slash commands with either `/queue /review` or `/review /queue`
|
|
11
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
|
|
12
14
|
- Replays queued input in order once the session becomes idle
|
|
13
15
|
- Replays queued commands as a visible `/command` message before executing them
|
|
14
16
|
- Registers `/queue` as a real OpenCode slash command
|
|
@@ -37,6 +39,8 @@ While the agent is busy:
|
|
|
37
39
|
/queue continue after the current task finishes
|
|
38
40
|
/queue /review
|
|
39
41
|
/queue /commit
|
|
42
|
+
continue after the current task finishes /queue
|
|
43
|
+
/review /queue
|
|
40
44
|
/queue list
|
|
41
45
|
/queue clear
|
|
42
46
|
```
|
|
@@ -46,6 +50,8 @@ When the session is idle:
|
|
|
46
50
|
```text
|
|
47
51
|
/queue hello
|
|
48
52
|
/queue /review
|
|
53
|
+
hello /queue
|
|
54
|
+
/review /queue
|
|
49
55
|
/queue
|
|
50
56
|
```
|
|
51
57
|
|
|
@@ -55,9 +61,11 @@ Queued items stay hidden while the current run is still working, then replay aut
|
|
|
55
61
|
|
|
56
62
|
- This is a `/queue` plugin, not a keyboard shortcut plugin. OpenCode plugins cannot currently register custom TUI keybindings.
|
|
57
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.
|
|
58
65
|
- Idle `/queue /command` immediately runs the nested command.
|
|
59
66
|
- `/queue` and `/queue list` show the in-memory queue for the current session.
|
|
60
67
|
- `/queue clear` drops all currently queued items for the current session.
|
|
68
|
+
- OpenCode shell mode is not supported because it uses a separate shell execution path outside normal prompt and slash-command handling.
|
|
61
69
|
|
|
62
70
|
## License
|
|
63
71
|
|
package/index.ts
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
import type {
|
|
3
|
-
AgentPartInput,
|
|
4
|
-
FilePart,
|
|
5
|
-
FilePartInput,
|
|
6
|
-
SubtaskPartInput,
|
|
7
|
-
TextPart,
|
|
8
|
-
TextPartInput,
|
|
9
|
-
} from "@opencode-ai/sdk"
|
|
2
|
+
import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
|
|
10
3
|
|
|
11
4
|
const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
|
|
12
|
-
const
|
|
5
|
+
const SUFFIX = /^([\s\S]*?)\s+\/queue\s*$/
|
|
6
|
+
const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
|
|
13
7
|
const HANDLED = "__QUEUE_HANDLED__"
|
|
14
8
|
|
|
15
9
|
type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
|
|
16
|
-
type
|
|
17
|
-
type
|
|
10
|
+
type Model = { providerID: string; modelID: string }
|
|
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 }
|
|
14
|
+
|
|
15
|
+
type Item =
|
|
16
|
+
| { kind: "prompt"; info: Info; text: string; parts: InputPart[] }
|
|
17
|
+
| { kind: "command"; info: Info; text: string; cmd: string; args: string; files: FilePartInput[] }
|
|
18
|
+
|
|
19
|
+
type Op =
|
|
20
|
+
| { kind: "list" }
|
|
21
|
+
| { kind: "clear" }
|
|
22
|
+
| { kind: "prompt"; text: string; body: string }
|
|
23
|
+
| { kind: "command"; text: string; cmd: string; args: string }
|
|
18
24
|
|
|
19
25
|
const label = (body: string, files: number) => {
|
|
20
26
|
const text = body.trim() || `${files} attachment${files === 1 ? "" : "s"}`
|
|
21
27
|
return text.length > 72 ? `${text.slice(0, 69)}...` : text
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
30
|
+
const parse = (body: string, files = 0): Op => {
|
|
31
|
+
const text = body.trim()
|
|
32
|
+
if (!files) {
|
|
33
|
+
if (!text || text === "list") return { kind: "list" }
|
|
34
|
+
if (text === "clear") return { kind: "clear" }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const match = text.match(CMD)
|
|
38
|
+
if (match) return { kind: "command", text, cmd: match[1], args: match[2] ?? "" }
|
|
39
|
+
return { kind: "prompt", text: label(body, files), body }
|
|
27
40
|
}
|
|
28
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
|
+
|
|
29
46
|
export const QueuePlugin: Plugin = async ({ client }) => {
|
|
30
47
|
const queue = new Map<string, Item[]>()
|
|
31
48
|
const hidden = new Set<string>()
|
|
@@ -35,89 +52,78 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
35
52
|
const toast = (message: string, variant: "info" | "error", duration = 2500) =>
|
|
36
53
|
client.tui.showToast({ body: { message, variant, duration } }).catch(() => undefined)
|
|
37
54
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
text.synthetic = true
|
|
42
|
-
text.ignored = true
|
|
55
|
+
const stop = async (message: string, variant: "info" | "error" = "info", duration = 5000): Promise<never> => {
|
|
56
|
+
await toast(message, variant, duration)
|
|
57
|
+
throw new Error(HANDLED)
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
else queue.set(sid, [item])
|
|
60
|
+
const hide = (id: string, part: TextPart) => {
|
|
61
|
+
hidden.add(id)
|
|
62
|
+
Object.assign(part, { text: "", synthetic: true, ignored: true })
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
}
|
|
65
|
+
const files = (parts: { type: string }[]) => parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
66
|
+
|
|
67
|
+
const manage = (sid: string, op: Extract<Op, { kind: "list" | "clear" }>) => {
|
|
68
|
+
if (op.kind === "list") return (queue.get(sid) ?? []).map((item, i) => `${i + 1}. ${item.text}`).join("\n") || "Queue is empty"
|
|
56
69
|
|
|
57
|
-
const clear = (sid: string) => {
|
|
58
70
|
const count = queue.get(sid)?.length ?? 0
|
|
59
71
|
queue.delete(sid)
|
|
60
72
|
return count ? `Cleared ${count} queued item${count === 1 ? "" : "s"}` : "Queue is empty"
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
const latest = async (sid: string): Promise<Info | undefined> => {
|
|
76
|
+
const result = await client.session.messages({ path: { id: sid }, query: { limit: 100 } }).catch((error) => {
|
|
77
|
+
console.warn("QueuePlugin could not inspect session messages for queued placeholder metadata", error)
|
|
78
|
+
return []
|
|
79
|
+
})
|
|
80
|
+
|
|
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 }
|
|
68
85
|
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
|
|
69
92
|
|
|
70
|
-
|
|
93
|
+
const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
|
|
94
|
+
|
|
95
|
+
const replay = async (sid: string, item: Item) => {
|
|
96
|
+
if (item.kind === "command") {
|
|
97
|
+
await prompt(sid, item.info, [{ type: "text", text: item.text }, ...item.files], true)
|
|
98
|
+
await client.session.command({
|
|
71
99
|
path: { id: sid },
|
|
72
100
|
body: {
|
|
73
|
-
|
|
74
|
-
model: item.info.model
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
...opts(item.info),
|
|
102
|
+
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
|
|
103
|
+
command: item.cmd,
|
|
104
|
+
arguments: item.args,
|
|
105
|
+
parts: item.files,
|
|
106
|
+
} as any,
|
|
77
107
|
})
|
|
78
108
|
return
|
|
79
109
|
}
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
model: item.info.model,
|
|
86
|
-
noReply: true,
|
|
87
|
-
parts: [{ type: "text", text: item.text }, ...(item.files ?? [])],
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
await client.session.command({
|
|
92
|
-
path: { id: sid },
|
|
93
|
-
body: {
|
|
94
|
-
agent: item.info.agent,
|
|
95
|
-
model: `${item.info.model.providerID}/${item.info.model.modelID}`,
|
|
96
|
-
command: item.command,
|
|
97
|
-
arguments: item.arguments,
|
|
98
|
-
parts: item.files,
|
|
99
|
-
} as any,
|
|
100
|
-
})
|
|
111
|
+
if (item.parts.length) {
|
|
112
|
+
return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
|
|
113
|
+
}
|
|
114
|
+
console.warn("QueuePlugin skipped queued item without replayable content")
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
const flush = async (sid: string) => {
|
|
104
|
-
if (flushing.has(sid)) return
|
|
105
|
-
|
|
106
118
|
const list = queue.get(sid)
|
|
107
|
-
if (!list?.length) return
|
|
119
|
+
if (flushing.has(sid) || !list?.length) return
|
|
108
120
|
|
|
109
121
|
flushing.add(sid)
|
|
110
|
-
|
|
111
122
|
try {
|
|
112
|
-
while (list.length)
|
|
113
|
-
const item = list.shift()
|
|
114
|
-
if (!item) break
|
|
115
|
-
await replay(sid, item)
|
|
116
|
-
}
|
|
123
|
+
while (list.length) await replay(sid, list.shift()!)
|
|
117
124
|
} catch (error) {
|
|
118
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
119
125
|
console.error("QueuePlugin failed to flush queued input", error)
|
|
120
|
-
await toast(`Queue failed: ${message}`, "error")
|
|
126
|
+
await toast(`Queue failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
121
127
|
} finally {
|
|
122
128
|
if (list.length) queue.set(sid, list)
|
|
123
129
|
else queue.delete(sid)
|
|
@@ -128,10 +134,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
128
134
|
return {
|
|
129
135
|
config: async (cfg) => {
|
|
130
136
|
cfg.command ??= {}
|
|
131
|
-
cfg.command.queue = {
|
|
132
|
-
template: "",
|
|
133
|
-
description: "Queue input until the session is idle",
|
|
134
|
-
}
|
|
137
|
+
cfg.command.queue = { template: "", description: "Queue input until the session is idle" }
|
|
135
138
|
},
|
|
136
139
|
event: async ({ event }) => {
|
|
137
140
|
if (event.type !== "session.status") return
|
|
@@ -146,90 +149,81 @@ export const QueuePlugin: Plugin = async ({ client }) => {
|
|
|
146
149
|
await flush(sid)
|
|
147
150
|
},
|
|
148
151
|
"command.execute.before": async (input, output) => {
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
const sid = input.sessionID
|
|
151
153
|
const body = input.arguments ?? ""
|
|
152
|
-
const
|
|
153
|
-
const files = output.parts.filter((part): part is FilePart => part.type === "file").map((part) => ({ ...part }))
|
|
154
|
+
const parts = files(output.parts)
|
|
154
155
|
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
156
|
+
if (input.command !== "queue") {
|
|
157
|
+
const args = trailing(body)
|
|
158
|
+
if (args === undefined) return
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!cmd) {
|
|
163
|
-
output.parts.length = 0
|
|
164
|
-
output.parts.push({ type: "text", text: body } as any, ...files)
|
|
160
|
+
if (!busy.has(sid)) {
|
|
161
|
+
for (const part of output.parts) if (part.type === "text") part.text = strip(part.text)
|
|
165
162
|
return
|
|
166
163
|
}
|
|
167
164
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
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)) {
|
|
175
|
+
if (op.kind === "command") {
|
|
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 })
|
|
178
|
+
throw new Error(HANDLED)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
output.parts.length = 0
|
|
182
|
+
output.parts.push({ type: "text", text: op.body } as any, ...parts)
|
|
183
|
+
return
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
output.parts.length = 0
|
|
187
|
-
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...
|
|
187
|
+
output.parts.push({ type: "text", text: `/queue ${body}` } as any, ...parts)
|
|
188
188
|
},
|
|
189
|
-
"chat.message": async (
|
|
189
|
+
"chat.message": async (input, output) => {
|
|
190
|
+
const sid = input.sessionID
|
|
190
191
|
const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
|
|
191
192
|
if (!text) return
|
|
192
193
|
|
|
193
|
-
const body = text.text
|
|
194
|
+
const body = queued(text.text)
|
|
194
195
|
if (body === undefined) return
|
|
195
196
|
|
|
196
|
-
const
|
|
197
|
-
const
|
|
197
|
+
const parts = files(output.parts)
|
|
198
|
+
const op = parse(body, parts.length)
|
|
198
199
|
|
|
199
|
-
if (
|
|
200
|
+
if (op.kind === "list" || op.kind === "clear") {
|
|
200
201
|
hide(output.message.id, text)
|
|
201
|
-
await toast(
|
|
202
|
+
await toast(manage(sid, op), "info", 5000)
|
|
202
203
|
return
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
if (!busy.has(
|
|
206
|
-
if (
|
|
206
|
+
if (!busy.has(sid)) {
|
|
207
|
+
if (op.kind === "command") return
|
|
207
208
|
text.text = body
|
|
208
209
|
return
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
const info = { agent: output.message.agent, model: { ...output.message.model } }
|
|
227
|
-
const command = parseCommand(body)
|
|
228
|
-
const item = command
|
|
229
|
-
? { info, text: trimmed, files, ...command }
|
|
230
|
-
: { info, text: label(body, files.length), parts }
|
|
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")
|
|
217
|
+
const inputParts = () =>
|
|
218
|
+
output.parts.flatMap((part): InputPart[] => {
|
|
219
|
+
if (part.type === "text") return part.id === text.id ? (body ? [{ ...part, text: body }] : []) : [{ ...part }]
|
|
220
|
+
if (part.type === "file" || part.type === "agent" || part.type === "subtask") return [{ ...part }]
|
|
221
|
+
console.warn("QueuePlugin skipped unexpected part", part.type)
|
|
222
|
+
return []
|
|
223
|
+
})
|
|
224
|
+
const item: Item = op.kind === "command" ? { ...op, info, files: parts } : { ...op, info, parts: inputParts() }
|
|
231
225
|
|
|
232
|
-
|
|
226
|
+
queue.set(sid, [...(queue.get(sid) ?? []), item])
|
|
233
227
|
hide(output.message.id, text)
|
|
234
228
|
await toast(`Queued: ${item.text}`, "info")
|
|
235
229
|
},
|
package/package.json
CHANGED