opencode-queue 0.9.1 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/index.ts +73 -38
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -33,6 +33,9 @@ do this next /queue front
33
33
  /queue front /review
34
34
  /review /queue front
35
35
 
36
+ /queue /compact
37
+ /queue front /compact
38
+
36
39
  /queue !ls
37
40
  /queue front !pwd
38
41
 
@@ -57,6 +60,8 @@ do this next /queue front
57
60
  | `/review /queue` | Queue a slash command using trailing syntax. |
58
61
  | `/queue front /review` | Queue a slash command before existing queued entries. |
59
62
  | `/review /queue front` | Queue a slash command before existing queued entries using trailing syntax. |
63
+ | `/queue /compact` | Queue OpenCode's built-in TUI `/compact` command. |
64
+ | `/queue front /compact` | Queue OpenCode's built-in TUI `/compact` command before existing queued entries. |
60
65
  | `/queue !ls` | Queue an OpenCode shell block. |
61
66
  | `/queue front !ls` | Queue an OpenCode shell block before existing queued entries. |
62
67
  | `/queue` | Show the current queue. |
@@ -87,6 +92,7 @@ When the session is idle:
87
92
  - `message /queue` sends `message` immediately.
88
93
  - `/queue /review` runs `/review` immediately.
89
94
  - `/review /queue` runs `/review` immediately.
95
+ - `/queue /compact` runs OpenCode's built-in TUI `/compact` command immediately.
90
96
  - `/queue !ls` runs `ls` immediately as an OpenCode shell block.
91
97
  - `/queue` and `/queue list` show the current queue.
92
98
  - `/queue stop` pauses automatic replay, and `/queue start` resumes it.
package/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
3
+ import { HttpServerResponse } from "effect/unstable/http"
3
4
 
4
5
  const QUEUE = /^\/queue(?:\s+([\s\S]*))?$/
5
6
  const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
6
7
  const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
7
8
  const ITEM_NUMBER = /^[1-9]\d*$/
8
- const HANDLED = "__QUEUE_HANDLED__"
9
+ const TUI_COMPACT = "session_compact"
9
10
 
10
11
  type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput
11
12
  type Model = { providerID: string; modelID: string }
@@ -20,11 +21,13 @@ type QueueInput = { body: string; front: boolean }
20
21
  type Item =
21
22
  | { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] }
22
23
  | { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] }
24
+ | { kind: "compact"; info: Info; source: string }
23
25
  | { kind: "shell"; info: Info; source: string; shell: string }
24
26
 
25
27
  type EntryOp =
26
28
  | { kind: "prompt"; label: string; body: string }
27
29
  | { kind: "command"; source: string; cmd: string; args: string }
30
+ | { kind: "compact"; source: string }
28
31
  | { kind: "shell"; source: string; shell: string }
29
32
 
30
33
  type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean }
@@ -85,7 +88,16 @@ const parse = (input: QueueInput, files = 0): Op => {
85
88
  }
86
89
 
87
90
  const match = text.match(CMD)
88
- if (match) return { kind: "command", source: text, cmd: match[1], args: match[2] ?? "", front }
91
+ if (match) {
92
+ const cmd = match[1]
93
+ const args = match[2] ?? ""
94
+ if (cmd === "compact") {
95
+ if (args.trim()) return { kind: "invalid", message: "Queue compact does not accept arguments" }
96
+ if (files) return { kind: "invalid", message: "Queue compact does not support attachments" }
97
+ return { kind: "compact", source: text, front }
98
+ }
99
+ return { kind: "command", source: text, cmd, args, front }
100
+ }
89
101
  return { kind: "prompt", label: brief(input.body, files), body: input.body, front }
90
102
  }
91
103
 
@@ -114,6 +126,14 @@ const control = (op: Op): op is ControlOp => {
114
126
  return false
115
127
  }
116
128
  }
129
+ const shouldQueue = (state?: State) => Boolean(state && (state.activity.kind !== "idle" || state.stopped))
130
+ const shouldDeclinePlan = (state?: State) => Boolean(state && (state.activity.kind === "sending" || (!state.stopped && state.items.length)))
131
+ const itemText = (item: Item) => (item.kind === "prompt" ? item.body.trim() || item.label : item.source)
132
+ // OpenCode's command hook has no cancel/noReply output. Throwing a raw Effect
133
+ // response is handled by OpenCode's HTTP layer as an empty successful command.
134
+ const handled = (): never => {
135
+ throw HttpServerResponse.empty({ status: 204 })
136
+ }
117
137
  const plan = (event: unknown): event is Ask => {
118
138
  if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
119
139
  const question = (event as Ask).properties?.questions?.[0]
@@ -139,7 +159,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
139
159
 
140
160
  const stop = async (message: string, variant: "info" | "error" = "info", duration = 5000): Promise<never> => {
141
161
  await toast(message, variant, duration)
142
- throw new Error(HANDLED)
162
+ return handled()
143
163
  }
144
164
 
145
165
  const no = async (id: string) => {
@@ -204,30 +224,42 @@ export const QueuePlugin: Plugin = async ({ client }) => {
204
224
 
205
225
  const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
206
226
 
207
- const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
208
227
  const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
228
+ // TUI command events target the focused session; queued replay must target the original session.
229
+ const compact = (sid: string, info: Info) =>
230
+ client.session.summarize({
231
+ path: { id: sid },
232
+ body: { providerID: info.model.providerID, modelID: info.model.modelID },
233
+ throwOnError: true,
234
+ })
209
235
 
210
236
  const replay = async (sid: string, item: Item) => {
211
- if (item.kind === "shell") return shell(sid, item.shell, item.info)
212
-
213
- if (item.kind === "command") {
214
- await client.session.command({
215
- path: { id: sid },
216
- body: {
217
- ...opts(item.info),
218
- model: `${item.info.model.providerID}/${item.info.model.modelID}`,
219
- command: item.cmd,
220
- arguments: item.args,
221
- parts: item.files,
222
- } as any,
223
- })
224
- return
225
- }
237
+ switch (item.kind) {
238
+ case "shell":
239
+ return shell(sid, item.shell, item.info)
240
+ case "compact":
241
+ return compact(sid, item.info)
242
+ case "command":
243
+ return client.session.command({
244
+ path: { id: sid },
245
+ body: {
246
+ ...opts(item.info),
247
+ model: `${item.info.model.providerID}/${item.info.model.modelID}`,
248
+ command: item.cmd,
249
+ arguments: item.args,
250
+ parts: item.files,
251
+ } as any,
252
+ })
253
+ case "prompt": {
254
+ if (!item.parts.length) {
255
+ console.warn("QueuePlugin skipped queued item without replayable content")
256
+ return
257
+ }
226
258
 
227
- if (item.parts.length) {
228
- return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
259
+ const parts = item.parts.map((part) => ({ ...part, id: undefined }))
260
+ return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any })
261
+ }
229
262
  }
230
- console.warn("QueuePlugin skipped queued item without replayable content")
231
263
  }
232
264
 
233
265
  const advance = (sid: string) => {
@@ -289,10 +321,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
289
321
 
290
322
  switch (op.kind) {
291
323
  case "list": {
292
- const list =
293
- current.items
294
- .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
295
- .join("\n") || "Queue is empty"
324
+ const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
296
325
  return current.stopped ? `${list}\nQueue is stopped` : list
297
326
  }
298
327
  case "clear":
@@ -323,8 +352,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
323
352
  event: async ({ event }) => {
324
353
  if (plan(event)) {
325
354
  const sid = event.properties.sessionID
326
- const current = sessions.get(sid)
327
- if (!current || (current.activity.kind !== "sending" && (current.stopped || !current.items.length))) return
355
+ if (!shouldDeclinePlan(sessions.get(sid))) return
328
356
  await no(event.properties.id)
329
357
  await toast("Declined plan approval to continue queued work", "info")
330
358
  return
@@ -366,9 +394,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
366
394
  const queued = parseSuffix(body)
367
395
  if (!queued) return
368
396
 
369
- const current = sessions.get(sid)
370
- const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
371
- if (!shouldQueue) {
397
+ if (!shouldQueue(sessions.get(sid))) {
372
398
  for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
373
399
  return
374
400
  }
@@ -377,22 +403,25 @@ export const QueuePlugin: Plugin = async ({ client }) => {
377
403
  return
378
404
  }
379
405
 
380
- const current = sessions.get(sid)
381
- const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped))
382
406
  const op = parse(parsePrefix(body), parts.length)
383
407
 
384
408
  if (control(op)) return stop(await manage(sid, op))
385
409
  if (op.kind === "invalid") return stop(op.message, "error")
386
410
 
387
- if (!shouldQueue) {
411
+ if (!shouldQueue(sessions.get(sid))) {
388
412
  if (op.kind === "shell") {
389
413
  await shell(sid, op.shell, await run(sid))
390
- throw new Error(HANDLED)
414
+ return handled()
415
+ }
416
+
417
+ if (op.kind === "compact") {
418
+ await client.tui.executeCommand({ body: { command: TUI_COMPACT }, throwOnError: true })
419
+ return handled()
391
420
  }
392
421
 
393
422
  if (op.kind === "command") {
394
423
  await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
395
- throw new Error(HANDLED)
424
+ return handled()
396
425
  }
397
426
 
398
427
  output.parts.splice(0, output.parts.length, { type: "text", text: op.body } as any, ...parts)
@@ -412,6 +441,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
412
441
  const current = state(sid)
413
442
  const parts = files(output.parts)
414
443
  const op = parse(request, parts.length)
444
+ const meta = input as Meta
415
445
 
416
446
  if (control(op)) {
417
447
  hide(output.message.id, text)
@@ -427,6 +457,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
427
457
 
428
458
  if (current.activity.kind === "idle" && !current.stopped) {
429
459
  if (op.kind === "command") return
460
+ if (op.kind === "compact") {
461
+ hide(output.message.id, text)
462
+ await compact(sid, { agent: output.message.agent, model: output.message.model, variant: meta.variant, controls: meta.controls, fast: meta.fast })
463
+ return
464
+ }
430
465
  if (op.kind === "shell") {
431
466
  hide(output.message.id, text)
432
467
  await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
@@ -436,13 +471,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
436
471
  return
437
472
  }
438
473
 
439
- const meta = input as Meta
440
474
  const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
441
475
  const prior = await latest(sid)
442
476
  if (prior) Object.assign(output.message, opts(prior))
443
477
  else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
444
478
  let item: Item
445
479
  if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
480
+ else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
446
481
  else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
447
482
  else {
448
483
  item = {
@@ -462,7 +497,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
462
497
  if (op.front) current.items.unshift(item)
463
498
  else current.items.push(item)
464
499
  hide(output.message.id, text)
465
- await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
500
+ await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
466
501
  },
467
502
  "experimental.chat.messages.transform": async (_, output) => {
468
503
  output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-queue",
4
- "version": "0.9.1",
4
+ "version": "0.10.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",
@@ -37,6 +37,9 @@
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit"
39
39
  },
40
+ "dependencies": {
41
+ "effect": "4.0.0-beta.59"
42
+ },
40
43
  "devDependencies": {
41
44
  "@opencode-ai/plugin": "^1.14.37",
42
45
  "@opencode-ai/sdk": "^1.14.37",