opencode-queue 0.13.0 → 0.13.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 +4 -4
  2. package/index.ts +54 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -72,9 +72,9 @@ do this next /queue front
72
72
  | `/queue list` | Show the current queue. |
73
73
  | `/queue stop` | Pause automatic sending of queued entries. |
74
74
  | `/queue start` | Resume automatic sending of queued entries. |
75
- | `/queue always` | Show whether automatic queueing is enabled for this project. |
76
- | `/queue always on` | Automatically queue plain input while the session is busy. |
77
- | `/queue always off` | Require `/queue` again. |
75
+ | `/queue always` | Show whether automatic queueing is enabled globally. |
76
+ | `/queue always on` | Enable automatic queueing in every project. |
77
+ | `/queue always off` | Disable automatic queueing in every project. |
78
78
  | `/queue flush` | Send all queued entries immediately. |
79
79
  | `/queue clear` | Clear the current queue. |
80
80
  | `/queue clear 1` | Clear item 1 from the current queue. |
@@ -109,7 +109,7 @@ When the session is idle:
109
109
  - `/queue flush` sends all queued entries immediately in one batch.
110
110
  - `/queue clear` clears the current queue, and `/queue clear 1` clears a specific queued item.
111
111
 
112
- Queues are scoped to the current project and session. They are stored in OpenCode's user data directory and restored with their previous running or stopped state after OpenCode restarts or crashes. The `always` setting applies to the whole project and is stored with its queues. Restored queues do not replay just because the session starts idle; a running queue resumes after the session becomes busy and then finishes successfully. A send interrupted by a crash remains queued because the plugin cannot know whether OpenCode accepted it before exiting.
112
+ Queues are scoped to the current project and session. They are stored in OpenCode's user data directory and restored with their previous running or stopped state after OpenCode restarts or crashes. The `always` setting applies to every OpenCode project. Restored queues do not replay just because the session starts idle; a running queue resumes after the session becomes busy and then finishes successfully. A send interrupted by a crash remains queued because the plugin cannot know whether OpenCode accepted it before exiting.
113
113
 
114
114
  ## Notes
115
115
 
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
2
2
  import type { AgentPartInput, FilePart, FilePartInput, SubtaskPartInput, TextPart, TextPartInput } from "@opencode-ai/sdk"
3
3
  import { HttpServerResponse } from "effect/unstable/http"
4
4
  import { createHash, randomUUID } from "node:crypto"
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
5
+ import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
6
6
  import { homedir } from "node:os"
7
7
  import { dirname, join } from "node:path"
8
8
 
@@ -43,8 +43,8 @@ type ControlOp =
43
43
 
44
44
  type Activity = { kind: "idle" } | { kind: "restored" } | { kind: "busy" } | { kind: "sending"; idle: boolean; items: Item[] }
45
45
  type State = { items: Item[]; activity: Activity; stopped: boolean; failed: boolean; hidden: Set<string> }
46
- type Draft = Pick<State, "items" | "stopped" | "hidden"> & { always: boolean }
47
- type Store = { version: 1; projectID: string; always: boolean; sessions: Record<string, { items: Item[]; stopped: boolean; hidden: string[] }> }
46
+ type Draft = Pick<State, "items" | "stopped" | "hidden">
47
+ type Store = { version: 1; projectID: string; sessions: Record<string, { items: Item[]; stopped: boolean; hidden: string[] }> }
48
48
  type Placeholder = { id: string; part: TextPart }
49
49
 
50
50
  type Op =
@@ -197,6 +197,17 @@ const dataHome = () => {
197
197
  return join(homedir(), ".local", "share")
198
198
  }
199
199
 
200
+ const writeJson = async (path: string, value: unknown, commit = rename) => {
201
+ await mkdir(dirname(path), { recursive: true })
202
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
203
+ try {
204
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
205
+ await commit(temporary, path)
206
+ } finally {
207
+ await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary storage", error))
208
+ }
209
+ }
210
+
200
211
  export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
201
212
  const sessions = new Map<string, State>()
202
213
  const deleted = new Set<string>()
@@ -204,17 +215,28 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
204
215
  const internalCommands: { sid: string; command: string; args: string; used: boolean }[] = []
205
216
  const origin = randomUUID()
206
217
  const post = (client as unknown as { _client?: { post?: Post } })._client?.post
207
- const path = join(dataHome(), "opencode", "opencode-queue", `${createHash("sha256").update(project.id).digest("hex")}.json`)
208
- let alwaysQueue = false
218
+ const root = join(dataHome(), "opencode", "opencode-queue")
219
+ const path = join(root, `${createHash("sha256").update(project.id).digest("hex")}.json`)
220
+ const settingsPath = join(root, "settings.json")
209
221
  let writes = Promise.resolve()
210
222
 
223
+ const readAlways = async () => {
224
+ try {
225
+ const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"))
226
+ if (record(parsed) && typeof parsed.always === "boolean") return parsed.always
227
+ console.warn("QueuePlugin ignored invalid global settings", settingsPath)
228
+ } catch (error) {
229
+ if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load global settings", error)
230
+ }
231
+ return false
232
+ }
233
+ let legacyAlways = false
234
+
211
235
  try {
212
236
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"))
213
237
  if (!record(parsed) || parsed.version !== 1 || parsed.projectID !== project.id || !record(parsed.sessions)) {
214
238
  console.warn("QueuePlugin ignored invalid queue storage", path)
215
239
  } else {
216
- if (typeof parsed.always === "boolean") alwaysQueue = parsed.always
217
- else if (parsed.always !== undefined) console.warn("QueuePlugin ignored invalid always queue setting", path)
218
240
  for (const [sid, value] of Object.entries(parsed.sessions)) {
219
241
  if (!record(value) || typeof value.stopped !== "boolean" || !Array.isArray(value.items)) {
220
242
  console.warn("QueuePlugin skipped invalid stored session", sid)
@@ -228,28 +250,27 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
228
250
  const activity: Activity = { kind: items.length && !value.stopped ? "restored" : "idle" }
229
251
  if (items.length || value.stopped || hidden.size) sessions.set(sid, { items, activity, stopped: value.stopped, failed: false, hidden })
230
252
  }
253
+ legacyAlways = parsed.always === true
231
254
  }
232
255
  } catch (error) {
233
256
  if (!record(error) || error.code !== "ENOENT") console.error("QueuePlugin failed to load queue storage", error)
234
257
  }
235
-
258
+ if (legacyAlways) {
259
+ try {
260
+ await writeJson(settingsPath, { always: true }, link)
261
+ } catch (error) {
262
+ if (!record(error) || error.code !== "EEXIST") throw error
263
+ }
264
+ }
236
265
  const save = async (sid?: string, draft?: Draft) => {
237
- const stored: Store = { version: 1, projectID: project.id, always: draft?.always ?? alwaysQueue, sessions: {} }
266
+ const stored: Store = { version: 1, projectID: project.id, sessions: {} }
238
267
  for (const [id, current] of sessions) {
239
268
  if (deleted.has(id) || (id === sid && !draft)) continue
240
269
  const durable = id === sid ? draft! : current
241
270
  const items = current.activity.kind === "sending" ? [...current.activity.items, ...durable.items] : durable.items
242
271
  if (items.length || durable.stopped || durable.hidden.size) stored.sessions[id] = { items, stopped: durable.stopped, hidden: [...durable.hidden] }
243
272
  }
244
- const contents = `${JSON.stringify(stored, null, 2)}\n`
245
- await mkdir(dirname(path), { recursive: true })
246
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
247
- try {
248
- await writeFile(temporary, contents, { mode: 0o600 })
249
- await rename(temporary, path)
250
- } finally {
251
- await rm(temporary, { force: true }).catch((error) => console.warn("QueuePlugin failed to remove temporary queue storage", error))
252
- }
273
+ await writeJson(path, stored)
253
274
  }
254
275
 
255
276
  const serialize = <T>(action: () => Promise<T>) => {
@@ -267,7 +288,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
267
288
  return current
268
289
  }
269
290
 
270
- const automaticallyQueue = (sid: string) => alwaysQueue && shouldQueue(sessions.get(sid))
291
+ const automaticallyQueue = async (sid: string) => shouldQueue(sessions.get(sid)) && (await readAlways())
271
292
 
272
293
  const store = async (sid: string, current: State, draft: Draft, placeholder?: Placeholder) => {
273
294
  try {
@@ -279,7 +300,6 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
279
300
  current.items.splice(0, current.items.length, ...draft.items)
280
301
  current.stopped = draft.stopped
281
302
  current.hidden = draft.hidden
282
- alwaysQueue = draft.always
283
303
  if (placeholder) Object.assign(placeholder.part, { text: "", synthetic: true, ignored: true })
284
304
  }
285
305
 
@@ -287,7 +307,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
287
307
  serialize(async () => {
288
308
  if (deleted.has(sid)) throw new Error(`QueuePlugin cannot persist queue state for deleted session ${sid}`)
289
309
  const current = state(sid)
290
- const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden), always: alwaysQueue }
310
+ const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden) }
291
311
  if (placeholder) draft.hidden.add(placeholder.id)
292
312
  const value = mutate(draft)
293
313
  await store(sid, current, draft, placeholder)
@@ -451,7 +471,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
451
471
 
452
472
  const items = current.items.slice(0, count)
453
473
  if (placeholder) {
454
- const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id), always: alwaysQueue }
474
+ const draft: Draft = { items: [...current.items], stopped: current.stopped, hidden: new Set(current.hidden).add(placeholder.id) }
455
475
  await store(sid, current, draft, placeholder)
456
476
  if (deleted.has(sid)) return undefined
457
477
  }
@@ -519,6 +539,16 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
519
539
  return result.failed ? `${message}; ${result.failed} failed` : message
520
540
  }
521
541
 
542
+ if (op.kind === "always") {
543
+ const enabled = await serialize(async () => {
544
+ if (op.enabled === undefined) return readAlways()
545
+ await writeJson(settingsPath, { always: op.enabled })
546
+ return op.enabled
547
+ })
548
+ if (placeholder) await persist(sid, placeholder, () => undefined)
549
+ return `Always queue is ${enabled ? "on" : "off"} globally`
550
+ }
551
+
522
552
  const message = await persist(sid, placeholder, (draft) => {
523
553
  switch (op.kind) {
524
554
  case "list": {
@@ -533,9 +563,6 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
533
563
  case "start":
534
564
  draft.stopped = false
535
565
  return "Queue started"
536
- case "always":
537
- if (op.enabled !== undefined) draft.always = op.enabled
538
- return `Always queue is ${draft.always ? "on" : "off"} for this project`
539
566
  }
540
567
  })
541
568
  if (op.kind === "start") {
@@ -618,7 +645,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
618
645
  }
619
646
 
620
647
  if (!isQueue(input.command)) {
621
- const queued = parseSuffix(body) ?? (automaticallyQueue(sid) ? { body } : undefined)
648
+ const queued = parseSuffix(body) ?? ((await automaticallyQueue(sid)) ? { body } : undefined)
622
649
  if (!queued) return
623
650
 
624
651
  if (!shouldQueue(sessions.get(sid))) {
@@ -672,7 +699,7 @@ export const QueuePlugin: Plugin = async ({ client, project, directory }) => {
672
699
  const text = output.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)
673
700
  if (!text) return
674
701
 
675
- const request = parseInput(text.text) ?? (automaticallyQueue(sid) ? { body: text.text } : undefined)
702
+ const request = parseInput(text.text) ?? ((await automaticallyQueue(sid)) ? { body: text.text } : undefined)
676
703
  if (!request) return
677
704
 
678
705
  const current = state(sid)
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.13.0",
4
+ "version": "0.13.1",
5
5
  "type": "module",
6
6
  "description": "Queue OpenCode prompts and slash commands until the agent is idle",
7
7
  "main": "./index.ts",