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