opencode-queue 0.9.0 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/index.ts +101 -50
  3. package/package.json +1 -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
@@ -6,6 +6,7 @@ const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/
6
6
  const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/
7
7
  const ITEM_NUMBER = /^[1-9]\d*$/
8
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,14 +21,17 @@ 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
- type State = { items: Item[]; busy: boolean; flushing: boolean; stopped: boolean; failed: boolean }
33
+ type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean }
34
+ type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean }
31
35
 
32
36
  type Op =
33
37
  | { kind: "list" }
@@ -84,7 +88,16 @@ const parse = (input: QueueInput, files = 0): Op => {
84
88
  }
85
89
 
86
90
  const match = text.match(CMD)
87
- 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
+ }
88
101
  return { kind: "prompt", label: brief(input.body, files), body: input.body, front }
89
102
  }
90
103
 
@@ -113,6 +126,9 @@ const control = (op: Op): op is ControlOp => {
113
126
  return false
114
127
  }
115
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)
116
132
  const plan = (event: unknown): event is Ask => {
117
133
  if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false
118
134
  const question = (event as Ask).properties?.questions?.[0]
@@ -127,7 +143,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
127
143
  const state = (sid: string) => {
128
144
  let current = sessions.get(sid)
129
145
  if (!current) {
130
- current = { items: [], busy: false, flushing: false, stopped: false, failed: false }
146
+ current = { items: [], activity: { kind: "idle" }, stopped: false, failed: false }
131
147
  sessions.set(sid, current)
132
148
  }
133
149
  return current
@@ -203,30 +219,68 @@ export const QueuePlugin: Plugin = async ({ client }) => {
203
219
 
204
220
  const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast })
205
221
 
206
- const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any })
207
222
  const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } })
223
+ // TUI command events target the focused session; queued replay must target the original session.
224
+ const compact = (sid: string, info: Info) =>
225
+ client.session.summarize({
226
+ path: { id: sid },
227
+ body: { providerID: info.model.providerID, modelID: info.model.modelID },
228
+ throwOnError: true,
229
+ })
208
230
 
209
231
  const replay = async (sid: string, item: Item) => {
210
- if (item.kind === "shell") return shell(sid, item.shell, item.info)
211
-
212
- if (item.kind === "command") {
213
- await client.session.command({
214
- path: { id: sid },
215
- body: {
216
- ...opts(item.info),
217
- model: `${item.info.model.providerID}/${item.info.model.modelID}`,
218
- command: item.cmd,
219
- arguments: item.args,
220
- parts: item.files,
221
- } as any,
222
- })
232
+ switch (item.kind) {
233
+ case "shell":
234
+ return shell(sid, item.shell, item.info)
235
+ case "compact":
236
+ return compact(sid, item.info)
237
+ case "command":
238
+ return client.session.command({
239
+ path: { id: sid },
240
+ body: {
241
+ ...opts(item.info),
242
+ model: `${item.info.model.providerID}/${item.info.model.modelID}`,
243
+ command: item.cmd,
244
+ arguments: item.args,
245
+ parts: item.files,
246
+ } as any,
247
+ })
248
+ case "prompt": {
249
+ if (!item.parts.length) {
250
+ console.warn("QueuePlugin skipped queued item without replayable content")
251
+ return
252
+ }
253
+
254
+ const parts = item.parts.map((part) => ({ ...part, id: undefined }))
255
+ return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any })
256
+ }
257
+ }
258
+ }
259
+
260
+ const advance = (sid: string) => {
261
+ const current = state(sid)
262
+ if (current.activity.kind !== "idle" || current.stopped || !current.items.length) return
263
+ void flush(sid, 1)
264
+ }
265
+
266
+ const settle = (sid: string, resume: boolean) => {
267
+ const current = state(sid)
268
+ current.activity = { kind: "idle" }
269
+ if (current.failed) {
270
+ current.failed = false
223
271
  return
224
272
  }
225
273
 
226
- if (item.parts.length) {
227
- return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined })))
274
+ if (resume) advance(sid)
275
+ }
276
+
277
+ const idle = (sid: string) => {
278
+ const current = state(sid)
279
+ if (current.activity.kind === "sending") {
280
+ current.activity.idle = true
281
+ return
228
282
  }
229
- console.warn("QueuePlugin skipped queued item without replayable content")
283
+ if (current.activity.kind === "busy") settle(sid, true)
230
284
  }
231
285
 
232
286
  const flush = async (sid: string, count = Infinity) => {
@@ -234,11 +288,11 @@ export const QueuePlugin: Plugin = async ({ client }) => {
234
288
  const items = current.items.splice(0, count)
235
289
  if (!items.length) return { sent: 0, failed: 0 }
236
290
 
237
- current.flushing = true
238
291
  let failed = 0
239
292
  const retry: Item[] = []
240
293
  try {
241
294
  for (const item of items) {
295
+ current.activity = { kind: "sending", idle: false }
242
296
  try {
243
297
  await replay(sid, item)
244
298
  } catch (error) {
@@ -250,26 +304,19 @@ export const QueuePlugin: Plugin = async ({ client }) => {
250
304
  }
251
305
  } finally {
252
306
  if (retry.length) current.items.unshift(...retry)
253
- current.flushing = false
307
+ const replayCompleted = current.activity.kind === "sending" && current.activity.idle
308
+ if (replayCompleted) settle(sid, count === 1 && failed === 0)
309
+ else current.activity = failed ? { kind: "idle" } : { kind: "busy" }
254
310
  }
255
311
  return { sent: items.length - failed, failed }
256
312
  }
257
313
 
258
- const advance = (sid: string) => {
259
- const current = state(sid)
260
- if (!current.items.length || current.busy || current.stopped || current.flushing) return
261
- void flush(sid, 1)
262
- }
263
-
264
314
  const manage = async (sid: string, op: ControlOp) => {
265
315
  const current = state(sid)
266
316
 
267
317
  switch (op.kind) {
268
318
  case "list": {
269
- const list =
270
- current.items
271
- .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`)
272
- .join("\n") || "Queue is empty"
319
+ const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty"
273
320
  return current.stopped ? `${list}\nQueue is stopped` : list
274
321
  }
275
322
  case "clear":
@@ -300,8 +347,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
300
347
  event: async ({ event }) => {
301
348
  if (plan(event)) {
302
349
  const sid = event.properties.sessionID
303
- const current = sessions.get(sid)
304
- if (!current || (!current.flushing && (current.stopped || !current.items.length))) return
350
+ if (!shouldDeclinePlan(sessions.get(sid))) return
305
351
  await no(event.properties.id)
306
352
  await toast("Declined plan approval to continue queued work", "info")
307
353
  return
@@ -318,23 +364,21 @@ export const QueuePlugin: Plugin = async ({ client }) => {
318
364
  }
319
365
 
320
366
  if (event.type === "session.idle") {
321
- const current = state(event.properties.sessionID)
322
- current.busy = false
323
- if (!current.failed) advance(event.properties.sessionID)
324
- current.failed = false
367
+ idle(event.properties.sessionID)
325
368
  return
326
369
  }
327
370
 
328
371
  if (event.type !== "session.status") return
329
372
 
330
- const current = state(event.properties.sessionID)
373
+ const sid = event.properties.sessionID
374
+ const current = state(sid)
331
375
  if (event.properties.status.type !== "idle") {
332
- current.busy = true
376
+ if (current.activity.kind !== "sending") current.activity = { kind: "busy" }
333
377
  current.failed = false
334
378
  return
335
379
  }
336
380
 
337
- current.busy = false
381
+ idle(sid)
338
382
  },
339
383
  "command.execute.before": async (input, output) => {
340
384
  const sid = input.sessionID
@@ -345,9 +389,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
345
389
  const queued = parseSuffix(body)
346
390
  if (!queued) return
347
391
 
348
- const current = sessions.get(sid)
349
- const shouldQueue = Boolean(current?.busy || current?.stopped)
350
- if (!shouldQueue) {
392
+ if (!shouldQueue(sessions.get(sid))) {
351
393
  for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text)
352
394
  return
353
395
  }
@@ -356,19 +398,22 @@ export const QueuePlugin: Plugin = async ({ client }) => {
356
398
  return
357
399
  }
358
400
 
359
- const current = sessions.get(sid)
360
- const shouldQueue = Boolean(current?.busy || current?.stopped)
361
401
  const op = parse(parsePrefix(body), parts.length)
362
402
 
363
403
  if (control(op)) return stop(await manage(sid, op))
364
404
  if (op.kind === "invalid") return stop(op.message, "error")
365
405
 
366
- if (!shouldQueue) {
406
+ if (!shouldQueue(sessions.get(sid))) {
367
407
  if (op.kind === "shell") {
368
408
  await shell(sid, op.shell, await run(sid))
369
409
  throw new Error(HANDLED)
370
410
  }
371
411
 
412
+ if (op.kind === "compact") {
413
+ await client.tui.executeCommand({ body: { command: TUI_COMPACT }, throwOnError: true })
414
+ throw new Error(HANDLED)
415
+ }
416
+
372
417
  if (op.kind === "command") {
373
418
  await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any })
374
419
  throw new Error(HANDLED)
@@ -391,6 +436,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
391
436
  const current = state(sid)
392
437
  const parts = files(output.parts)
393
438
  const op = parse(request, parts.length)
439
+ const meta = input as Meta
394
440
 
395
441
  if (control(op)) {
396
442
  hide(output.message.id, text)
@@ -404,8 +450,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
404
450
  return
405
451
  }
406
452
 
407
- if (!current.busy && !current.stopped) {
453
+ if (current.activity.kind === "idle" && !current.stopped) {
408
454
  if (op.kind === "command") return
455
+ if (op.kind === "compact") {
456
+ hide(output.message.id, text)
457
+ await compact(sid, { agent: output.message.agent, model: output.message.model, variant: meta.variant, controls: meta.controls, fast: meta.fast })
458
+ return
459
+ }
409
460
  if (op.kind === "shell") {
410
461
  hide(output.message.id, text)
411
462
  await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model })
@@ -415,13 +466,13 @@ export const QueuePlugin: Plugin = async ({ client }) => {
415
466
  return
416
467
  }
417
468
 
418
- const meta = input as Meta
419
469
  const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast }
420
470
  const prior = await latest(sid)
421
471
  if (prior) Object.assign(output.message, opts(prior))
422
472
  else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context")
423
473
  let item: Item
424
474
  if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell }
475
+ else if (op.kind === "compact") item = { kind: "compact", info, source: op.source }
425
476
  else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts }
426
477
  else {
427
478
  item = {
@@ -441,7 +492,7 @@ export const QueuePlugin: Plugin = async ({ client }) => {
441
492
  if (op.front) current.items.unshift(item)
442
493
  else current.items.push(item)
443
494
  hide(output.message.id, text)
444
- await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info")
495
+ await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info")
445
496
  },
446
497
  "experimental.chat.messages.transform": async (_, output) => {
447
498
  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.0",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",