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