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
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ updates:
target-branch: develop
schedule:
interval: weekly
commit-message:
prefix: "chore(deps)"
groups:
actions:
patterns: ["*"]
Expand All @@ -21,6 +23,8 @@ updates:
target-branch: develop
schedule:
interval: weekly
commit-message:
prefix: "chore(deps)"
groups:
dev:
dependency-type: development
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ pending waits remain compatible and continue using the original data directory.

## Install

Version 0.2.1 targets OpenCode V2 `0.0.0-beta-19242`. Its plugin SDK, Effect,
and OpenTUI versions are pinned together. Older plugin versions can fail to
load after an OpenCode upgrade with `scope.state.finalizers.set`; update the
plugin rather than changing credentials or deleting pending waits.

The plugin has two halves and needs an entry in **two** config files. The TUI
half provides the commands; the server half owns the timers so a wait still
fires once the TUI is closed.
Expand Down Expand Up @@ -65,7 +70,7 @@ With options:
### Compatibility

The V2 plugin API is beta. This release is built and verified against
`@opencode-ai/plugin@0.0.0-beta-17595` (`opencode2 --version` → `v0.0.0-beta-17595`).
`@opencode-ai/plugin@0.0.0-beta-19242` (`opencode2 --version` → `v0.0.0-beta-19242`).
If your OpenCode is on a different beta build, check for a matching release of
this plugin.

Expand Down Expand Up @@ -162,11 +167,18 @@ bun install
bun run check # typecheck + lint + test
```

To run it against a local checkout, point a config entry at the file:
To run it against a local checkout, add a local plugin directory with an
`index.ts` that re-exports the checkout's server entrypoint:

```ts
export { default } from "/absolute/path/to/opencode2-waits/src/index.ts"
```

Then configure that local plugin directory:

```jsonc
{
"plugins": ["/absolute/path/to/opencode2-waits/src/index.ts"],
"plugins": ["/absolute/path/to/local-plugin"],
}
```

Expand Down
618 changes: 577 additions & 41 deletions bun.lock

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode2-waits",
"version": "0.2.0",
"version": "0.2.1",
"description": "OpenCode V2 plugin that defers a prompt: /wait 1hour implement this",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -40,17 +40,17 @@
"test": "bun test"
},
"dependencies": {
"@opencode-ai/plugin": "0.0.0-beta-17595",
"@opencode-ai/schema": "0.0.0-beta-17595",
"@opentui/core": "0.5.3",
"@opentui/solid": "0.5.3",
"effect": "4.0.0-beta.107",
"@opencode-ai/plugin": "0.0.0-beta-19242",
"@opencode-ai/schema": "0.0.0-beta-19242",
"@opentui/core": "0.5.10",
"@opentui/solid": "0.5.10",
"effect": "4.0.0-rc.112",
"solid-js": "^1.9.14"
},
"devDependencies": {
"@biomejs/biome": "2.5.6",
"@opencode-ai/theme": "0.0.0-beta-17595",
"@opentui/keymap": "0.5.3",
"@opencode-ai/theme": "0.0.0-beta-19242",
"@opentui/keymap": "0.5.10",
"@types/bun": "1.3.14",
"@types/node": "24.12.2",
"typescript": "5.8.2"
Expand Down
107 changes: 105 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Effect } from "effect"

export interface Definition {
readonly name: string
readonly description: string
Expand All @@ -6,8 +11,8 @@ export interface Definition {

/**
* The fallback trigger for clients without a plugin runtime — the web and
* desktop apps only list plugins in settings, they cannot run one. A server
* command is a prompt template, so each of these costs one model turn: the
* desktop apps only list plugins in settings, they cannot run one. Each of
* these submits its template as a prompt, so it costs one model turn: the
* template's only job is to make the agent call the matching tool once, with
* the argument text passed straight through. In the TUI the client-side slash
* commands intercept /wait before submission, so these never fire there.
Expand Down Expand Up @@ -56,3 +61,101 @@ export const definitions: ReadonlyArray<Definition> = [
].join("\n"),
},
]

/**
* The slice of the OpenCode session API a command needs to submit its
* template, kept narrow like `Delivery.SessionPort` so the server plugin
* context and a plain client both satisfy it.
*/
export interface SessionPort {
readonly prompt: (input: {
readonly sessionID: Session.ID
readonly text: string
readonly files?: PromptInput.Prompt["files"]
readonly agents?: PromptInput.Prompt["agents"]
readonly skills?: PromptInput.Prompt["skills"]
readonly delivery?: SessionInbox.Delivery
}) => Effect.Effect<unknown, unknown>
}

/** What the host hands an executable command when the user runs it. */
export interface Invocation {
readonly sessionID: Session.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}

/** Structural mirror of the host's `CommandDefinition`. */
export interface Command {
readonly name: string
readonly description: string
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}

const token = /\$(ARGUMENTS|[1-9][0-9]*)/g

/** The highest `$n` the template refers to, so the last one can absorb the
* rest of the argument text. */
const arity = (template: string): number => {
let highest = 0
for (const match of template.matchAll(token)) {
const name = match[1]
if (name !== undefined && name !== "ARGUMENTS") highest = Math.max(highest, Number(name))
}
return highest
}

/** Splits on whitespace until one placeholder is left, which takes everything
* that remains: `/wait 1hour do the thing` gives `$1` the duration and `$2`
* the whole prompt, spaces included. */
const positional = (args: string, count: number): ReadonlyArray<string> => {
const parts: Array<string> = []
let rest = args
while (parts.length < count - 1) {
const boundary = rest.search(/\s/)
if (boundary < 0) break
parts.push(rest.slice(0, boundary))
rest = rest.slice(boundary + 1).trimStart()
}
// Left off rather than pushed as `""` so a missing argument renders empty.
if (rest !== "") parts.push(rest)
return parts
}

/**
* Expands `$ARGUMENTS` and `$1`..`$n` in a template.
*
* The host did this itself while a command was only a template; an executable
* command is handed the raw argument text instead. A replacer function is used
* rather than a replacement string so `$&` or `$1` typed by the user is
* inserted verbatim, and the single pass keeps substituted text from being
* expanded again.
*/
export const render = (template: string, args: string): string => {
const text = args.trim()
const parts = positional(text, arity(template))
return template.replace(token, (_match, name: string) =>
name === "ARGUMENTS" ? text : (parts[Number(name) - 1] ?? ""),
)
}

/**
* Turns a definition into an executable command that submits the rendered
* template, carrying the invocation's attachments and delivery over so an
* `@file` mention still reaches the agent.
*/
export const command = (definition: Definition, session: SessionPort): Command => ({
name: definition.name,
description: definition.description,
execute: (input) =>
session
.prompt({
sessionID: input.sessionID,
text: render(definition.template, input.prompt.text),
files: input.prompt.files,
agents: input.prompt.agents,
skills: input.prompt.skills,
delivery: input.delivery,
})
.pipe(Effect.asVoid),
})
5 changes: 1 addition & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@ export default Plugin.define({
})
yield* ctx.command.transform((commands) => {
for (const definition of Commands.definitions) {
commands.update(definition.name, (command) => {
command.description = definition.description
command.template = definition.template
})
commands.add(Commands.command(definition, ctx.session))
}
})
}
Expand Down
153 changes: 153 additions & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, test } from "bun:test"
import { Session } from "@opencode-ai/schema/session"
import { Skill } from "@opencode-ai/schema/skill"
import { Cause, Effect, Exit, Schema } from "effect"
import * as Commands from "../src/commands.ts"

type PromptCall = Parameters<Commands.SessionPort["prompt"]>[0]

/** A stub `Commands.SessionPort` that records every submission, so a command
* can be run without an OpenCode server. */
const stubSession = (result: Effect.Effect<unknown, unknown> = Effect.void) => {
const calls: Array<PromptCall> = []
const session: Commands.SessionPort = {
prompt: (input) => {
calls.push(input)
return result
},
}
return { session, calls }
}

const definition = (name: string): Commands.Definition => {
const found = Commands.definitions.find((candidate) => candidate.name === name)
if (found === undefined) throw new Error(`no such command: ${name}`)
return found
}

const invocation = (
text: string,
overrides: Partial<Commands.Invocation> = {},
): Commands.Invocation => ({
sessionID: Session.ID.create(),
prompt: { text },
delivery: "queue",
...overrides,
})

describe("Commands.render", () => {
test("gives $1 the duration and $2 the whole remaining prompt", () => {
const rendered = Commands.render(definition("wait").template, "1hour do the thing, please")

expect(rendered).toContain("- `duration`: 1hour")
expect(rendered).toContain("\nOPENCODE_WAIT_PROMPT\ndo the thing, please\nOPENCODE_WAIT_PROMPT")
})

test("trims the argument text before splitting it", () => {
const rendered = Commands.render(definition("wait").template, " 1hour do it ")

expect(rendered).toContain("- `duration`: 1hour")
expect(rendered).toContain("\nOPENCODE_WAIT_PROMPT\ndo it\nOPENCODE_WAIT_PROMPT")
})

test("inserts user text verbatim, expanding neither placeholders nor $ sequences in it", () => {
// `String.replace` treats `$1`, `$&` and `$$` in a *replacement string* as
// substitution syntax, and a second pass would expand a placeholder the
// user typed. Either one would rewrite the prompt being scheduled.
const prompt = "print $1 $2 $ARGUMENTS $& $$ $`"
const rendered = Commands.render(definition("wait").template, `1hour ${prompt}`)

expect(rendered).toContain(`\nOPENCODE_WAIT_PROMPT\n${prompt}\nOPENCODE_WAIT_PROMPT`)
})

test("renders a missing argument as empty rather than leaving the placeholder", () => {
const rendered = Commands.render(definition("wait").template, "1hour")

expect(rendered).toContain("- `duration`: 1hour")
expect(rendered).toContain("\nOPENCODE_WAIT_PROMPT\n\nOPENCODE_WAIT_PROMPT")
expect(rendered).not.toContain("$2")
})

test("renders every placeholder as empty when no arguments were given", () => {
const rendered = Commands.render(definition("wait").template, "")

expect(rendered).toContain("- `duration`: \n")
expect(rendered).not.toContain("$1")
expect(rendered).not.toContain("$2")
})

test("gives $ARGUMENTS the whole cancellation argument", () => {
const template = definition("wait-cancel").template

expect(Commands.render(template, " all ")).toContain("The user wrote: all")
expect(Commands.render(template, "w7")).toContain("The user wrote: w7")
expect(Commands.render(template, "")).toContain("The user wrote: \n")
})

test("leaves a template without placeholders untouched", () => {
const template = definition("wait-list").template

expect(Commands.render(template, "ignored input")).toBe(template)
})
})

describe("Commands.command", () => {
test("every definition becomes a command carrying its name and description", () => {
const { session } = stubSession()

expect(Commands.definitions.length).toBeGreaterThan(0)
for (const candidate of Commands.definitions) {
const command = Commands.command(candidate, session)
expect(command.name).toBe(candidate.name)
expect(command.description).toBe(candidate.description)
}
})

test("submits the rendered template to the invoking session with its delivery", async () => {
const { session, calls } = stubSession()
const wait = definition("wait")
const command = Commands.command(wait, session)
const input = invocation("1hour do it", { delivery: "steer" })

const result = await Effect.runPromise(command.execute(input))

expect(result).toBeUndefined()
expect(calls).toHaveLength(1)
const call = calls[0]
if (call === undefined) throw new Error("expected session.prompt to have been called")
expect(call.sessionID).toBe(input.sessionID)
expect(call.delivery).toBe("steer")
expect(call.text).toBe(Commands.render(wait.template, "1hour do it"))
})

test("forwards the invocation's attachments so mentions still reach the agent", async () => {
const { session, calls } = stubSession()
const files = [{ uri: "file:///tmp/notes.md" }]
const agents = [{ name: "build" }]
const skills = [{ id: Schema.decodeSync(Skill.ID)("testing") }]
const command = Commands.command(definition("wait"), session)

await Effect.runPromise(
command.execute(
invocation("1hour do it", { prompt: { text: "1hour do it", files, agents, skills } }),
),
)

const call = calls[0]
if (call === undefined) throw new Error("expected session.prompt to have been called")
expect(call.files).toEqual(files)
expect(call.agents).toEqual(agents)
expect(call.skills).toEqual(skills)
})

test("fails when the submission fails, so the host can report it", async () => {
const { session, calls } = stubSession(Effect.fail("submission rejected"))
const command = Commands.command(definition("wait-list"), session)

const exit = await Effect.runPromiseExit(command.execute(invocation("")))

expect(calls).toHaveLength(1)
if (Exit.isSuccess(exit)) throw new Error("expected the command to fail")
expect(Cause.squash(exit.cause)).toBe("submission rejected")
})
})
Loading