Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Enter these in OpenCode's normal input:
| `/queue !command` | Run a shell command after the session becomes idle. |
| `/queue:carry`, `/queue:carry-front` | Transfer following entries into a fresh session at this boundary. |
| `/queue:clear`, `/queue:clear 2 4` | Remove all waiting entries or selected numbered entries. |
| `/queue:edit [first\|last\|n] text` | Replace a waiting entry's text, and optionally its attachments, in place. |
| `/queue:stop`, `/queue:start` | Pause or resume replay. |
| `/queue:abort` | Cancel active work and pause replay, including a queued shell or pending model switch. |
| `/queue:flush` | Release waiting entries through the next carry boundary. |
Expand All @@ -42,6 +43,8 @@ Replay releases one entry after a successful final response and an idle transiti

`/queue:now` can switch the active model at the next checkpoint: a running tool finishes, then the plugin stops the old execution, switches settings, and continues in the same conversation. Flush groups matching agent/model/variant selections; each new group waits for the previous one to finish successfully.

`/queue:edit` replaces the text of one waiting entry in-place, along with any attachments submitted with the edit. The last entry is edited when the target is omitted. Numbering matches `/queue:list` and `/queue:clear`. A leading number, `first`, or `last` is read as the target. Replacement text is parsed again, and mentions in the replacement are rebased onto the new text. Queue-control text and fresh-session boundaries cannot be edited. New attachments replace the entry's previous files; without new attachments, previous files stay attached when the new entry kind accepts them (prompts and slash commands), but their mention ranges are dropped as they refer to the old text. Shells and compaction entries drop inherited files, and agent and skill references from the old text are removed.

Carry boundaries create an empty session in the current directory and transfer the remaining entries, files, and paused state. The TUI follows the new session. A failed transfer leaves the source intact. Flush stops at the boundary.

Slash commands resolve when released. If a command was removed, its original text becomes a prompt. Shell commands use `$SHELL` and show output as a plugin message, with a bounded preview and a full log. Failed or cancelled shell commands stay queued for an explicit retry.
Expand Down
4 changes: 2 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Schema } from "effect"
import { randomUUID } from "node:crypto"
import { join, resolve } from "node:path"
import { QueueEngine } from "./src/engine.js"
import { shiftPrompt } from "./src/parser.js"
import { offsetPrompt, shiftPrompt } from "./src/parser.js"
import { QueueRpc } from "./src/rpc.js"
import { dataDirectory, loadStore, queuePath, writeJSON } from "./src/storage.js"
import { shell } from "./src/shell.js"
Expand Down Expand Up @@ -154,7 +154,7 @@ export default Plugin.define({
const session = await ctx.session.get({ sessionID })
if (!session.agent || !session.model) throw new Error("Select an agent and model before using queue commands")
const { snapshot, list } = await engine.submit({ sessionID, requestID: randomUUID(),
selection: { agent: session.agent, model: encodeModel(session.model) }, prompt: encodePrompt(prompt) }, name)
selection: { agent: session.agent, model: encodeModel(session.model) }, prompt: encodePrompt(offsetPrompt(prompt, name)) }, name)
const waiting = snapshot.items.filter((item) => !snapshot.sending.includes(item.id))
const status = `${waiting.length} waiting · ${snapshot.stopped ? "paused" : snapshot.gate === "interrupted" ? "suspended" : "running"}`
const message = [status, ...(snapshot.error ? [snapshot.error] : []), ...(list ? waiting.map((item, i) => {
Expand Down
148 changes: 147 additions & 1 deletion src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test } from "node:test"
import { EventEmitter } from "node:events"
import { randomUUID } from "node:crypto"
import { QueueEngine, type Host, type Store, type Session } from "./engine.js"
import { parse } from "./parser.js"
import { offsetPrompt, parse } from "./parser.js"
import type { Entry, Input, Notification, Selection, Snapshot } from "./types.js"

const A: Selection = { agent: "build", model: { providerID: "test", id: "a", variant: "xhigh" } }
Expand Down Expand Up @@ -78,6 +78,30 @@ test("parses queue syntax, commands, shells and carries without mutating attachm
assert.throws(() => parse({ text: "/queue !pwd", files }))
})

test("queue edits parse an optional target and keep the replacement text intact", () => {
const edit = (text: string, extra: Partial<Parameters<typeof parse>[0]> = {}) => {
const parsed = parse({ text, ...extra })
assert.ok("control" in parsed && parsed.control === "queue:edit")
return parsed.edit
}
assert.deepEqual(edit("/queue:edit corrected"), { target: "last", prompt: { text: "corrected" } })
assert.deepEqual(edit("/queue:edit first corrected"), { target: "first", prompt: { text: "corrected" } })
assert.deepEqual(edit("/queue:edit last 2 apples"), { target: "last", prompt: { text: "2 apples" } })
assert.deepEqual(edit("/queue:edit 2 /review diff"), { target: 2, prompt: { text: "/review diff" } })
assert.deepEqual(edit("/queue:edit 3 42"), { target: 3, prompt: { text: "42" } })
assert.deepEqual(edit("/queue:edit 1e2 apples"), { target: "last", prompt: { text: "1e2 apples" } })
for (const text of ["/queue:edit", "/queue:edit first", "/queue:edit 2", "/queue:edit 0 corrected", "/queue:edit 999999999999999999 corrected"])
assert.throws(() => edit(text))
const text = "/queue:edit 2 use @file"
const files = [{ uri: "file:///image.png", mention: { start: text.indexOf("@file"), end: text.length, text: "@file" } }]
assert.deepEqual(edit(text, { files }), {
target: 2,
prompt: { text: "use @file", files: [{ uri: "file:///image.png", mention: { start: text.indexOf("@file") - text.indexOf("use"), end: "use @file".length, text: "@file" } }] },
})
// Mentions inside the control prefix or target token cannot fall within the replacement text.
assert.throws(() => edit("/queue:edit 2 use", { files: [{ uri: "file:///image.png", mention: { start: 12, end: 13, text: "2" } }] }), /Mention is outside/)
})

test("suffix queue markers remain literal prompt text", () => {
for (const text of ["hello /q", "hello /queue"]) {
const parsed = parse({ text })
Expand All @@ -98,6 +122,23 @@ test("native command arguments keep nested slash commands and mention ranges", (
assert.equal(input.agents[0]!.mention.start, 9)
})

test("native command arguments rebase mentions measured from the full input line", async () => {
const f = fixture()
try {
await f.submit("/queue:stop"); await f.submit("/queue one"); await f.submit("/queue two")
const text = "2 summarize @notes.txt "
const prompt = { text, files: [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 24, end: 34, text: "@notes.txt" } }] }
// Full line -> handler text removes "/queue:edit " (12 chars): mention 12-22.
const rebased = offsetPrompt(prompt, "queue:edit")
assert.deepEqual(rebased.files?.[0]?.mention, { start: 12, end: 22, text: "@notes.txt" })
// Handler text -> entry replacement removes "2 ": mention 10-20.
await f.engine.submit(f.input(text, A, { prompt: rebased }), "queue:edit")
const saved = await f.engine.snapshot("one")
assert.equal(saved.items[1]?.prompt.text, "summarize @notes.txt")
assert.deepEqual(saved.items[1]?.prompt.files, [{ uri: "file:///notes.txt", name: "notes.txt", mention: { start: 10, end: 20, text: "@notes.txt" } }])
} finally { await f.engine.dispose() }
})

test("native list feedback follows parsed commands and treats attachment-only input as entries", async () => {
const f = fixture()
try {
Expand Down Expand Up @@ -300,6 +341,111 @@ test("front insertion and selective clear validate all indices before mutating",
await f.engine.dispose()
})

test("editing replaces text in place and keeps the entry's selection, files and position", async () => {
const f = fixture()
const files = [{ uri: "file:///image.png", name: "image.png", mention: { start: 7, end: 10, text: "two" } }]
try {
await f.submit("/queue:stop"); await f.submit("/queue one"); await f.submit("/queue two", B, { prompt: { text: "/queue two", files } })
const before = (await f.engine.snapshot("one")).items
await f.submit("/queue:edit last corrected")
const after = (await f.engine.snapshot("one")).items
assert.deepEqual(after.map((item) => item.prompt.text), ["one", "corrected"])
assert.deepEqual(after.map((item) => item.id), before.map((item) => item.id))
assert.deepEqual(after[1]!.selection, B)
// Mention offsets describe the old text and are dropped with the edit.
assert.deepEqual(after[1]!.prompt.files, [{ uri: "file:///image.png", name: "image.png" }])
assert.equal(f.sent.length, 0)
await f.submit("/queue:edit tail")
assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["one", "tail"])
assert.equal(f.sent.length, 0)
} finally { await f.engine.dispose() }
})

test("editing into a shell or compaction entry drops attachments, while commands keep them", async () => {
const f = fixture()
const image = (name: string, text: string) => [{ uri: `file:///${name}.png`, name: `${name}.png`, mention: { start: 7, end: 7 + text.length, text } }]
try {
await f.submit("/queue:stop")
await f.submit("/queue one", A, { prompt: { text: "/queue one", files: image("first", "one") } })
await f.submit("/queue two", A, { prompt: { text: "/queue two", files: image("second", "two") } })
await f.submit("/queue:edit 1 /review diff")
let items = (await f.engine.snapshot("one")).items
assert.equal(items[0]!.kind, "command")
assert.deepEqual(items[0]!.prompt.files, [{ uri: "file:///first.png", name: "first.png" }])
await f.submit("/queue:edit 1 !pwd")
items = (await f.engine.snapshot("one")).items
assert.equal(items[0]!.kind, "shell")
assert.equal(items[0]!.prompt.files, undefined)
await f.submit("/queue:edit 2 /compact")
items = (await f.engine.snapshot("one")).items
assert.equal(items[1]!.kind, "compact")
assert.equal(items[1]!.prompt.files, undefined)
} finally { await f.engine.dispose() }
})

test("editing with new attachments replaces inherited files and keeps their mentions", async () => {
const f = fixture()
try {
await f.submit("/queue:stop")
await f.submit("/queue one", A, { prompt: { text: "/queue one", files: [{ uri: "file:///old.png", name: "old.png", mention: { start: 7, end: 10, text: "one" } }] } })
const text = "/queue:edit 1 use @new"
await f.submit(text, A, { prompt: { text, files: [{ uri: "file:///new.png", name: "new.png", mention: { start: text.indexOf("@new"), end: text.length, text: "@new" } }] } })
const item = (await f.engine.snapshot("one")).items[0]!
assert.equal(item.prompt.text, "use @new")
assert.deepEqual(item.prompt.files, [{
uri: "file:///new.png",
name: "new.png",
mention: { start: text.indexOf("@new") - text.indexOf("use"), end: "use @new".length, text: "@new" },
}])
await assert.rejects(f.submit("/queue:edit 1 !pwd", A, { prompt: { text: "/queue:edit 1 !pwd", files: [{ uri: "file:///new.png" }] } }), /cannot have attachments/)
assert.equal((await f.engine.snapshot("one")).items[0]!.prompt.text, "use @new")
} finally { await f.engine.dispose() }
})

test("editing re-parses entry kinds and refuses queue controls, boundaries and out-of-range targets", async () => {
const f = fixture()
try {
await f.submit("/queue:stop"); await f.submit("/queue one")
await f.submit("/queue:edit first !pwd")
let items = (await f.engine.snapshot("one")).items
assert.equal(items[0]!.kind, "shell")
assert.equal(items[0]!.kind === "shell" ? items[0]!.command : undefined, "pwd")
await f.submit("/queue:edit 1 /review diff")
items = (await f.engine.snapshot("one")).items
assert.equal(items[0]!.kind, "command")
assert.equal(items[0]!.kind === "command" ? items[0]!.command : undefined, "review")
await assert.rejects(f.submit("/queue:edit 1 /queue:start"), /cannot create a queue command/)
await assert.rejects(f.submit("/queue:edit 2 nope"), /out of range/)
await assert.rejects(f.submit("/queue:edit"), /replacement text/)
assert.equal((await f.engine.snapshot("one")).items.length, 1)
await f.submit("/queue:clear"); await f.submit("/queue:carry")
await assert.rejects(f.submit("/queue:edit first nope"), /Fresh-session boundaries/)
} finally { await f.engine.dispose() }
})

test("editing numbers skip the entry currently being sent", async () => {
const f = fixture(), started = deferred(), release = deferred()
f.host.send = async (_sessionID, item) => { f.sent.push({ sessionID: "one", item }); started.resolve(); await release.promise }
try {
await f.submit("/queue active"); await started.promise
await f.submit("/queue waiting")
await f.submit("/queue:edit 1 corrected")
assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["active", "corrected"])
await assert.rejects(f.submit("/queue:edit 2 nope"), /out of range/)
release.resolve(); await f.engine.settled()
} finally { release.resolve(); await f.engine.dispose() }
})

test("a failed edit persists nothing and keeps the original text", async () => {
const f = fixture()
try {
await f.submit("/queue:stop"); await f.submit("/queue original")
f.host.persist = async () => { throw new Error("disk full") }
await assert.rejects(f.submit("/queue:edit first changed"), /disk full/)
assert.deepEqual((await f.engine.snapshot("one")).items.map((item) => item.prompt.text), ["original"])
} finally { await f.engine.dispose() }
})

test("disk failures preserve memory and no unpersisted entry is sent", async () => {
const f = fixture()
f.host.persist = async () => { throw new Error("disk full") }
Expand Down
43 changes: 42 additions & 1 deletion src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"
import { parse, type Parsed } from "./parser.js"
import { type Command, type Entry, type Input, type Notification, type Selection, type Snapshot } from "./types.js"
import { type Command, type Entry, type Input, type Notification, type Prompt, type Selection, type Snapshot } from "./types.js"

export type Session = { id: string; selection?: Selection; directory: string; outcome?: "succeeded" | "failed" | "interrupted" }
export interface Host {
Expand Down Expand Up @@ -122,6 +122,12 @@ export class QueueEngine {
saved.items = saved.items.filter((item) => !remove.has(item.id))
})
break
case "queue:edit": {
const edit = op.edit
if (!edit) throw new Error("Queue edit expects replacement text")
await this.edit(id, edit.target, edit.prompt)
break
}
case "queue:apply_model":
await this.transaction((draft) => {
for (const item of draft.sessions[id]?.items ?? []) if (item.id !== state.flight?.id && item.kind !== "carry")
Expand Down Expand Up @@ -170,6 +176,41 @@ export class QueueEngine {
this.changed(id); this.kick(id)
}

// Replace the text of a waiting entry in place. The caller already notified
// and kicked this session once the edit came back through submitInner.
private async edit(id: string, target: number | "first" | "last", input: Prompt): Promise<void> {
const parsed = parse(input)
if ("control" in parsed) throw new Error("Queue edit cannot create a queue command")
const { front: _front, delivery: _delivery, ...replacement } = parsed
await this.transaction((draft) => {
const state = this.state(id)
if (state.deleted) throw new Error("Session has been deleted")
const saved = draft.sessions[id]
if (!saved) throw new Error("Queue item number is out of range")
const waiting = saved.items.filter((item) => item.id !== state.flight?.id)
const index = target === "first" ? 0 : target === "last" ? waiting.length - 1 : target - 1
const previous = waiting[index]
if (!previous) throw new Error("Queue item number is out of range")
if (previous.kind === "carry") throw new Error("Fresh-session boundaries cannot be edited")
// Shell and compaction entries never carry attachments. Otherwise new
// attachments replace the inherited ones and keep their mentions, which
// the parser rebased onto the new text; inherited files outlive the old
// text, but their mention offsets do not.
let files: Prompt["files"]
if (replacement.kind !== "shell" && replacement.kind !== "compact")
files = replacement.prompt.files?.length
? replacement.prompt.files
: previous.prompt.files?.map(({ mention: _mention, ...file }) => file)
const position = saved.items.findIndex((item) => item.id === previous.id)
saved.items[position] = {
...replacement,
id: previous.id,
selection: structuredClone(previous.selection),
prompt: { ...replacement.prompt, ...(files?.length ? { files } : {}) },
} as Entry
})
}

async observe(id: string, event: "started" | "succeeded" | "failed" | "interrupted" | "deleted" | "rejected", sequence?: number): Promise<void> {
const state = this.state(id)
if (event === "rejected" && sequence !== undefined && sequence <= (state.startedSequence ?? 0)) return
Expand Down
31 changes: 30 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { commands, type Command, type Prompt, type QueuedInput } from "./types.js"

export type Parsed = { control: Command; indices?: number[] } | QueuedInput & {
export type Parsed = { control: Command; indices?: number[]; edit?: { target: number | "first" | "last"; prompt: Prompt } } | QueuedInput & {
front: boolean; delivery: "queue" | "now" | "normal"
}

Expand All @@ -14,6 +14,12 @@ export function shiftPrompt(input: Prompt, text: string, offset: number): Prompt
return prompt
}

// Native commands receive the text after "/<name> ", but their mention ranges
// still count from the start of the full input line.
export function offsetPrompt<T extends Prompt>(input: T, name: string): T {
return shiftPrompt(input, input.text, -(name.length + 2)) as T
}

export function parse(input: Prompt, command?: Command): Parsed {
let text = input.text.trim()
let offset = input.text.length - input.text.trimStart().length
Expand All @@ -33,6 +39,29 @@ export function parse(input: Prompt, command?: Command): Parsed {
throw new Error("Queue clear expects positive item numbers")
return { control, indices: [...new Set(numbers.map(Number))] }
}
if (control === "queue:edit") {
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text)
if (!match) throw new Error("Queue edit expects replacement text")
const token = match[1]!
const numbered = /^\d+$/.test(token)
const targeted = token === "first" || token === "last" || numbered
let target: number | "first" | "last" = "last"
if (token === "first" || token === "last") target = token
else if (numbered) {
if (!/^[1-9]\d*$/.test(token) || !Number.isSafeInteger(Number(token))) throw new Error("Queue edit expects positive item numbers")
target = Number(token)
}
let replacement = text
if (targeted) {
const rest = match[2] ?? ""
replacement = rest.trim()
// Mentions are relative to the submitted text, so move them past the
// control prefix and the target token before they reach the entry.
offset += text.length - rest.length + (rest.length - rest.trimStart().length)
}
if (!replacement) throw new Error("Queue edit expects replacement text")
return { control, edit: { target, prompt: shiftPrompt(input, replacement, -offset) } }
}
if (control && !["queue", "q", "queue:front", "queue:now", "queue:carry", "queue:carry-front"].includes(control)) {
if (text || files || mentions) throw new Error(`${control} does not accept input`)
return { control }
Expand Down
Loading
Loading