diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 87e2402..659b11e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability - url: https://github.com/davidprokopec/opencode-waits/security/advisories/new + url: https://github.com/davidprokopec/opencode-schedule/security/advisories/new about: Report security issues privately diff --git a/README.md b/README.md index b2c8c69..4e2a0be 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,37 @@ -# opencode-waits +# opencode-schedule -[![CI](https://github.com/davidprokopec/opencode-waits/actions/workflows/ci.yml/badge.svg)](https://github.com/davidprokopec/opencode-waits/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/opencode-waits)](https://www.npmjs.com/package/opencode-waits) -[![license](https://img.shields.io/npm/l/opencode-waits)](LICENSE) +[![CI](https://github.com/davidprokopec/opencode-schedule/actions/workflows/ci.yml/badge.svg)](https://github.com/davidprokopec/opencode-schedule/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/opencode-schedule)](https://www.npmjs.com/package/opencode-schedule) +[![license](https://img.shields.io/npm/l/opencode-schedule)](LICENSE) Defer a prompt in [OpenCode V2](https://opencode.ai/v2/docs/). Say what you want and when you want it, and OpenCode sends it to itself later. ``` -/wait 1hour implement this -/wait 60s run the test suite again -/wait 2h30m check whether the deploy settled +/schedule 1hour implement this +/schedule 60s run the test suite again +/schedule 2h30m check whether the deploy settled ``` -`/wait` returns straight away. Nothing runs, no context is held open, and no +`/schedule` returns straight away. Nothing runs, no context is held open, and no tokens are burned while the timer is pending. When the delay elapses the plugin submits the prompt text into the same session as an ordinary user prompt, and OpenCode acts on it exactly as if you had typed it yourself. -Now that OpenCode V2 is official, this plugin is published as `opencode-waits` -again. It was published as `opencode2-waits` during the V2 preview; new -releases use `opencode-waits`. To migrate, replace `opencode2-waits` (or -`opencode2-waits@beta`) with `opencode-waits` in `opencode.json(c)` and remove -any old waits entry from `cli.json`. -Existing pending waits remain compatible and continue using the original data -directory. The source lives in the -[`davidprokopec/opencode-waits`](https://github.com/davidprokopec/opencode-waits) -repository. +Previously published as `opencode-waits` and `opencode2-waits`. To migrate, +replace the old package entry with `opencode-schedule` in `opencode.json(c)` +and remove any old waits entry from `cli.json`. Do not load both packages. +Existing pending schedules and `/wait` commands remain compatible; duration +syntax and behavior are unchanged. The original data directory and internal +plugin identity are retained, so OpenCode may still list the internal ID as +`opencode2-waits`. ## Install Install globally with OpenCode's plugin manager: ```sh -opencode plugin add opencode-waits +opencode plugin add opencode-schedule ``` Or add the package to `plugins` in `opencode.json(c)`. One entry loads the @@ -43,12 +41,12 @@ configuration: ```jsonc // ~/.config/opencode/opencode.json(c) { - "plugins": ["opencode-waits"], + "plugins": ["opencode-schedule"], } ``` -No separate waits entry in `cli.json` is needed. The server owns the timers, -so closing the TUI or Desktop window does not cancel pending waits. +No separate entry in `cli.json` is needed. The server owns the timers, +so closing the TUI or Desktop window does not cancel pending schedules. With options: @@ -56,7 +54,7 @@ With options: { "plugins": [ { - "package": "opencode-waits", + "package": "opencode-schedule", "options": { "delivery": "queue", }, @@ -79,16 +77,17 @@ the current stable version. ## Commands -The TUI sidebar shows pending waits in an expanded Waits section. Click its -header to collapse or expand it; click a wait to open its actions. +The TUI sidebar shows pending prompts in an expanded Schedules section. Click +its header to collapse or expand it; click a schedule to open its actions. ### Desktop (experimental) -Open a session, then choose **Open Waits** from the command palette or use -`/wait-list` to open the Waits side panel. Schedule a prompt with a delay, -view pending waits and their countdowns, or cancel one wait or all waits in -the current session. `/wait` and `/wait-cancel` also run locally without a -model call. +Open a session and click **Schedules** in the side-panel toolbar, or choose +**Schedules** from the side panel's **+ / New tab** launcher. You can also +choose **Open Schedules** in the command palette or use `/schedule-list`. +Schedule a prompt with a delay, view pending schedules and their countdowns, +or cancel one or all schedules in the current session. `/schedule` and +`/schedule-cancel` also run locally without a model call. The GUI API is experimental and targets OpenCode Desktop 2.2.0. Browser/web-app plugin loading has not been verified. The Desktop panel currently supports @@ -96,7 +95,7 @@ scheduling, listing and cancellation; the TUI's edit, reschedule and send-now actions are not part of this GUI version. OpenCode 2.2.0 may show both server fallback and GUI entries in slash -suggestions. Typing `/wait`, `/wait-list` or `/wait-cancel` and submitting +suggestions. Typing `/schedule`, `/schedule-list` or `/schedule-cancel` and submitting directly uses the local GUI handler; the server entries remain available for clients without GUI support. @@ -104,13 +103,15 @@ clients without GUI support. | Command | Effect | | ----------------------- | ---------------------------------------------------------- | -| `/wait ` | Schedule `` for delivery after ``. | -| `/wait-list` | Show the waits still pending in this session. | -| `/wait-cancel ` | Cancel one wait, e.g. `/wait-cancel w1`. | -| `/wait-cancel all` | Cancel every wait pending in this session. | +| `/schedule ` | Schedule `` for delivery after ``. | +| `/schedule-list` | Show pending scheduled prompts. | +| `/schedule-cancel ` | Cancel one schedule, e.g. `/schedule-cancel w1`. | +| `/schedule-cancel all` | Cancel every schedule pending in this session. | -Waits are per session. `/wait-list` and `/wait-cancel` never see or touch waits -belonging to another session. +The old `/wait`, `/wait-list`, `/wait-cancel` commands remain compatibility +aliases with the same arguments. Cancellation is scoped to the current +session. Desktop lists schedules for the current session; the TUI manager +also groups schedules from other sessions as before. ## Durations @@ -133,9 +134,9 @@ the prompt. own, so `1h30` is rejected as ambiguous rather than guessed at. The maximum is 30 days. -## Waits survive restarts +## Schedules survive restarts -Each pending wait is a JSON file under +Each pending schedule is a JSON file under `$XDG_DATA_HOME/opencode-waits/waits/` (the legacy-compatible directory, with one file per wait, so the two halves never clobber each other's records). On startup the server half arms everything it finds, and fires anything already overdue @@ -155,7 +156,7 @@ A slash command defined by a *server* plugin is only a prompt template. Running one submits a prompt and schedules model execution — so it fails exactly when you are rate limited and most want to defer work. -A **TUI** slash command does not. The prompt matches `/wait …` and calls the +A **TUI** slash command does not. The prompt matches `/schedule …` and calls the plugin before any model dispatch, so scheduling costs no model call at all. That is why the terminal trigger lives in the TUI part. Desktop commands call the server's typed RPC directly, also without submitting a model prompt. @@ -164,8 +165,10 @@ Timers cannot live there, though: they would die when you close the TUI. So the TUI half only records the wait to disk, and the **server** half — running in the background service — owns the timers and delivers the prompt. -The server half always exposes `wait_schedule`, `wait_list` and `wait_cancel` as -tools and registers `/wait`, `/wait-list` and `/wait-cancel` as server-side +The server half exposes `schedule_create`, `schedule_list` and `schedule_cancel` +as tools, with `wait_schedule`, `wait_list` and `wait_cancel` compatibility +aliases, and registers `/schedule`, `/schedule-list` and `/schedule-cancel` +(plus their `/wait` aliases) as server-side commands. Clients without the TUI or GUI part can use these server commands; they ask the agent to call the tool and cost one model turn. The TUI and Desktop GUI parts provide their own model-free command handlers instead. @@ -174,6 +177,19 @@ Desktop GUI parts provide their own model-free command handlers instead. Requires [Bun](https://bun.sh). +Source is grouped by plugin surface: + +```text +src/ + server/ # timers, commands, tools and RPC handlers + tui/ # terminal commands, manager and sidebar + gui/ # Desktop panel, launchers and local commands + shared/ # contracts, durations and server/TUI persistence +``` + +Package exports select each surface; the GUI bundle imports only its shared +RPC contract, not filesystem or timer implementations. + ```sh bun install bun run check # typecheck + lint + test + GUI bundle build @@ -184,7 +200,7 @@ bundle first, then configure the package directory: ```jsonc { - "plugins": ["/absolute/path/to/opencode-waits"], + "plugins": ["/absolute/path/to/opencode-schedule"], } ``` diff --git a/SECURITY.md b/SECURITY.md index fcb33e9..4e3df1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ ## Reporting a vulnerability Report privately through -[GitHub Security Advisories](https://github.com/davidprokopec/opencode-waits/security/advisories/new). +[GitHub Security Advisories](https://github.com/davidprokopec/opencode-schedule/security/advisories/new). Please do not open a public issue for a vulnerability. ## What this plugin can do diff --git a/bun.lock b/bun.lock index 2809306..259b6c4 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "opencode-waits", + "name": "opencode-schedule", "dependencies": { "@opencode/plugin": "2.2.0", "@opencode/schema": "2.2.0", diff --git a/package.json b/package.json index 20cee53..a42da70 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,21 @@ { - "name": "opencode-waits", - "version": "0.4.0", - "description": "OpenCode V2 plugin that defers a prompt: /wait 1hour implement this", + "name": "opencode-schedule", + "version": "0.5.0", + "description": "Schedule a prompt in OpenCode: /schedule 1hour implement this", "license": "MIT", "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/davidprokopec/opencode-waits.git" + "url": "git+https://github.com/davidprokopec/opencode-schedule.git" }, - "homepage": "https://github.com/davidprokopec/opencode-waits#readme", - "bugs": "https://github.com/davidprokopec/opencode-waits/issues", + "homepage": "https://github.com/davidprokopec/opencode-schedule#readme", + "bugs": "https://github.com/davidprokopec/opencode-schedule/issues", "exports": { - ".": "./src/index.ts", - "./server": "./src/index.ts", - "./tui": "./src/tui.ts", + ".": "./src/server/index.ts", + "./server": "./src/server/index.ts", + "./tui": "./src/tui/index.ts", "./gui-experimental": "./dist/gui-experimental.cjs", - "./rpc": "./src/rpc.ts" + "./rpc": "./src/shared/rpc.ts" }, "files": [ "src", diff --git a/src/gui-commands.ts b/src/gui-commands.ts deleted file mode 100644 index 21b11ef..0000000 --- a/src/gui-commands.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Command, type Context, type Command as GuiCommand } from "@opencode/plugin/gui" -import { errorMessage, parseWaitArguments } from "./gui-utils.ts" -import { WaitsRpc } from "./rpc.ts" - -export function registerWaitCommands(ctx: Context): void { - const open = () => { - const session = ctx.sessions.current() - if (session) ctx.layout.open(`${ctx.id}:main`, session) - else - ctx.toast.show({ title: "No session open", description: "Open a session to view its waits." }) - } - ctx.add( - Command, - (): GuiCommand => ({ - id: "open", - title: ctx.t("command.open"), - section: "session", - run: open, - }), - ) - ctx.add( - Command, - (): GuiCommand => ({ - id: "list", - title: ctx.t("command.list"), - section: "session", - slash: { name: "wait-list", arguments: true }, - run: open, - }), - ) - ctx.add( - Command, - (): GuiCommand => ({ - id: "wait", - title: ctx.t("command.wait"), - description: "Schedule a prompt without sending it now", - section: "session", - slash: { name: "wait", arguments: true }, - run: async (raw = "") => { - const match = parseWaitArguments(raw) - if (!match) { - ctx.toast.show({ - title: "Add a delay and prompt", - description: "Use /wait ", - variant: "error", - }) - return - } - const session = ctx.sessions.current() - if (!session) { - ctx.toast.show({ - title: "No session open", - description: "Open a session before scheduling a wait.", - }) - return - } - try { - await session.server.client - .rpc(WaitsRpc) - .schedule({ sessionID: session.id, duration: match.duration, prompt: match.prompt }) - ctx.toast.show({ title: "Wait scheduled", variant: "success" }) - } catch (cause) { - ctx.toast.show({ - title: "Could not schedule wait", - description: errorMessage(cause, "Try again."), - variant: "error", - }) - } - }, - }), - ) - ctx.add( - Command, - (): GuiCommand => ({ - id: "cancel", - title: ctx.t("command.cancel"), - description: "Cancel one wait by ID, or every wait", - section: "session", - slash: { name: "wait-cancel", arguments: true }, - run: async (raw = "") => { - const argument = raw.trim() - if (!argument) { - ctx.toast.show({ - title: "Add an ID or all", - description: "Use /wait-cancel ", - variant: "error", - }) - return - } - const session = ctx.sessions.current() - if (!session) { - ctx.toast.show({ - title: "No session open", - description: "Open a session before cancelling waits.", - }) - return - } - try { - const rpc = session.server.client.rpc(WaitsRpc) - const result = - argument === "all" - ? `${await rpc.cancelAll({ sessionID: session.id })} waits cancelled.` - : (await rpc.cancel({ sessionID: session.id, id: argument })) - ? "Wait cancelled." - : "No matching wait in this session." - ctx.toast.show({ title: result }) - } catch (cause) { - ctx.toast.show({ - title: "Could not cancel wait", - description: errorMessage(cause, "Try again."), - variant: "error", - }) - } - }, - }), - ) -} diff --git a/src/gui/commands.ts b/src/gui/commands.ts new file mode 100644 index 0000000..6bf3173 --- /dev/null +++ b/src/gui/commands.ts @@ -0,0 +1,133 @@ +import { Command, type Context, type Command as GuiCommand } from "@opencode/plugin/gui" +import { WaitsRpc } from "../shared/rpc.ts" +import { errorMessage, parseWaitArguments } from "./utils.ts" + +export function registerWaitCommands(ctx: Context): void { + const schedule = async (raw = "") => { + const match = parseWaitArguments(raw) + if (!match) + return void ctx.toast.show({ + title: "Add a delay and prompt", + description: "Use /schedule ", + variant: "error", + }) + const session = ctx.sessions.current() + if (!session) + return void ctx.toast.show({ + title: "No session open", + description: "Open a session before scheduling a prompt.", + }) + try { + await session.server.client + .rpc(WaitsRpc) + .schedule({ sessionID: session.id, duration: match.duration, prompt: match.prompt }) + ctx.toast.show({ title: "Prompt scheduled", variant: "success" }) + } catch (cause) { + ctx.toast.show({ + title: "Could not schedule prompt", + description: errorMessage(cause, "Try again."), + variant: "error", + }) + } + } + const cancel = async (raw = "") => { + const argument = raw.trim() + if (!argument) + return void ctx.toast.show({ + title: "Add an ID or all", + description: "Use /schedule-cancel ", + variant: "error", + }) + const session = ctx.sessions.current() + if (!session) + return void ctx.toast.show({ + title: "No session open", + description: "Open a session before cancelling scheduled prompts.", + }) + try { + const rpc = session.server.client.rpc(WaitsRpc) + ctx.toast.show({ + title: + argument === "all" + ? `${await rpc.cancelAll({ sessionID: session.id })} scheduled prompts cancelled.` + : (await rpc.cancel({ sessionID: session.id, id: argument })) + ? "Scheduled prompt cancelled." + : "No matching scheduled prompt in this session.", + }) + } catch (cause) { + ctx.toast.show({ + title: "Could not cancel scheduled prompt", + description: errorMessage(cause, "Try again."), + variant: "error", + }) + } + } + const open = () => { + const session = ctx.sessions.current() + if (session) ctx.layout.open(`${ctx.id}:main`, session) + else + ctx.toast.show({ + title: "No session open", + description: "Open a session to view its schedules.", + }) + } + ctx.add( + Command, + (): GuiCommand => ({ + id: "open", + title: ctx.t("command.open"), + section: "session", + run: open, + }), + ) + ctx.add( + Command, + (): GuiCommand => ({ + id: "list", + title: ctx.t("command.list"), + section: "session", + slash: { name: "schedule-list", arguments: true }, + run: open, + }), + ) + ctx.add( + Command, + (): GuiCommand => ({ + id: "wait", + title: ctx.t("command.wait"), + description: "Schedule a prompt without sending it now", + section: "session", + slash: { name: "schedule", arguments: true }, + run: schedule, + }), + ) + ctx.add( + Command, + (): GuiCommand => ({ + id: "cancel", + title: ctx.t("command.cancel"), + description: "Cancel one scheduled prompt by ID, or all scheduled prompts", + section: "session", + slash: { name: "schedule-cancel", arguments: true }, + run: cancel, + }), + ) + + for (const [id, name, title, run] of [ + ["legacy-wait", "wait", "Schedule a prompt (legacy)", schedule], + ["legacy-wait-list", "wait-list", "Open scheduled prompts (legacy)", open], + ["legacy-wait-cancel", "wait-cancel", "Cancel a scheduled prompt (legacy)", cancel], + ] as const) { + ctx.add( + Command, + (): GuiCommand => ({ + id, + title, + hidden: true, + section: "session", + slash: { name, arguments: true }, + run, + }), + ) + } +} diff --git a/src/gui.tsx b/src/gui/index.tsx similarity index 81% rename from src/gui.tsx rename to src/gui/index.tsx index 44d1225..6f72f7d 100644 --- a/src/gui.tsx +++ b/src/gui/index.tsx @@ -1,9 +1,18 @@ /** @jsxImportSource solid-js */ -import { type Panel as GuiPanel, Panel, type PanelProps, Plugin, Style } from "@opencode/plugin/gui" +import { + type MenuItem as GuiMenuItem, + type Panel as GuiPanel, + MenuItem, + Panel, + type PanelProps, + Plugin, + Slot, + Style, +} from "@opencode/plugin/gui" import { createEffect, createSignal, For, onCleanup, Show } from "solid-js" -import { registerWaitCommands } from "./gui-commands.ts" -import { createSessionScope, errorMessage, remaining } from "./gui-utils.ts" -import { type WaitRow, WaitsRpc } from "./rpc.ts" +import { type WaitRow, WaitsRpc } from "../shared/rpc.ts" +import { registerWaitCommands } from "./commands.ts" +import { createSessionScope, errorMessage, remaining } from "./utils.ts" function WaitsPanel(props: PanelProps) { const [rows, setRows] = createSignal([]) @@ -45,7 +54,7 @@ function WaitsPanel(props: PanelProps) { } } catch (cause) { if (scope.isCurrent(target.key, current) && !mutationFailed) - setError(errorMessage(cause, "Could not load waits.")) + setError(errorMessage(cause, "Could not load schedules.")) } finally { if (request === requestGeneration && scope.isCurrent(target.key, current)) { requestKey = undefined @@ -97,7 +106,7 @@ function WaitsPanel(props: PanelProps) { } catch (cause) { if (scope.isCurrent(target.key, revision)) { mutationFailed = true - setError(errorMessage(cause, "Could not schedule this wait.")) + setError(errorMessage(cause, "Could not schedule this prompt.")) } } finally { if (scope.isCurrent(target.key, revision)) setBusy(false) @@ -117,7 +126,7 @@ function WaitsPanel(props: PanelProps) { } catch (cause) { if (scope.isCurrent(target.key, revision)) { mutationFailed = true - setError(errorMessage(cause, "Could not cancel this wait.")) + setError(errorMessage(cause, "Could not cancel this schedule.")) } } finally { if (scope.isCurrent(target.key, revision)) setBusy(false) @@ -137,26 +146,26 @@ function WaitsPanel(props: PanelProps) { } catch (cause) { if (scope.isCurrent(target.key, revision)) { mutationFailed = true - setError(errorMessage(cause, "Could not cancel waits.")) + setError(errorMessage(cause, "Could not cancel schedules.")) } } finally { if (scope.isCurrent(target.key, revision)) setBusy(false) } } return ( -
+
-

FOLLOW-UP QUEUE

+

SCHEDULED PROMPTS

- Waits {rows().length} + Schedules {rows().length}

@@ -205,13 +214,13 @@ function WaitsPanel(props: PanelProps) {

- Loading scheduled waits… + Loading schedules…

- Nothing waiting + No scheduled prompts

Schedule a prompt above and it will appear here.

@@ -226,7 +235,7 @@ function WaitsPanel(props: PanelProps) { type="button" onClick={() => void cancel(row.id)} disabled={busy()} - aria-label={`Cancel wait ${row.id}`} + aria-label={`Cancel schedule ${row.id}`} > Cancel @@ -245,17 +254,17 @@ function WaitsPanel(props: PanelProps) { export default Plugin.define({ i18n: { en: { - "command.open": "Open Waits", - "command.wait": "Schedule a wait", - "command.list": "Open scheduled waits", - "command.cancel": "Cancel a wait", + "command.open": "Open Schedules", + "command.wait": "Schedule a prompt", + "command.list": "Open scheduled prompts", + "command.cancel": "Cancel a scheduled prompt", }, }, setup(ctx) { const tab = { id: "main", get title() { - return "Waits" + return "Schedules" }, closable: "compact" as const, fallback: true, @@ -271,7 +280,7 @@ export default Plugin.define({ region: "side", mobile: { get title() { - return "Waits" + return "Schedules" }, order: 45, kind: "menu", @@ -281,6 +290,39 @@ export default Plugin.define({ render: (props) => , }), ) + ctx.add(Slot, { + at: "session.panel.end", + render: (input) => ( + + ), + }) + ctx.add(MenuItem, (): GuiMenuItem => { + const session = ctx.sessions.current() + return { + menu: "session.panel", + id: "schedules", + title: "Schedules", + icon: "outline-rotate-clockwise", + order: 45, + opened: !!session && ctx.layout.state(`${ctx.id}:main`, session) !== "closed", + run: () => { + const target = ctx.sessions.current() + if (target) ctx.layout.open(`${ctx.id}:main`, target) + else + ctx.toast.show({ + title: "No session open", + description: "Open a session to view scheduled prompts.", + }) + }, + } + }) registerWaitCommands(ctx) }, }) diff --git a/src/gui-utils.ts b/src/gui/utils.ts similarity index 100% rename from src/gui-utils.ts rename to src/gui/utils.ts diff --git a/src/commands.ts b/src/server/commands.ts similarity index 64% rename from src/commands.ts rename to src/server/commands.ts index e6dde2f..b81ae01 100644 --- a/src/commands.ts +++ b/src/server/commands.ts @@ -15,53 +15,84 @@ export interface Definition { * 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. + * commands intercept /schedule before submission, so these never fire there. + * + * Every template calls the canonical `schedule_*` tools, whether it is + * reached through a canonical name or a legacy `/wait*` alias. */ -export const definitions: ReadonlyArray = [ +const scheduleTemplate = [ + "The user wants a prompt delivered later, not now.", + "", + "Call the `schedule_create` tool exactly once with:", + "", + "- `duration`: $1", + "- `prompt`: the text between the OPENCODE_WAIT_PROMPT markers below, copied verbatim,", + " without the marker lines", + "", + "OPENCODE_WAIT_PROMPT", + "$2", + "OPENCODE_WAIT_PROMPT", + "", + "Do not start the requested work, do not read any files, and do not call any other tool.", + "After `schedule_create` returns, reply with one short line repeating what it reported.", +].join("\n") + +const listTemplate = [ + "Call the `schedule_list` tool exactly once and do not call any other tool.", + "Reply with its output and nothing else.", +].join("\n") + +const cancelTemplate = [ + "Call the `schedule_cancel` tool exactly once and do not call any other tool.", + "", + "The user wrote: $ARGUMENTS", + "", + "If that text is `all`, call the tool with `all` set to true.", + "Otherwise call the tool with `id` set to that text exactly.", + "If the text is empty, call the tool with no arguments.", + "Reply with the tool's output and nothing else.", +].join("\n") + +export const canonical: ReadonlyArray = [ + { + name: "schedule", + description: "Send a prompt to this session after a delay, e.g. /schedule 1hour do it", + template: scheduleTemplate, + }, + { + name: "schedule-list", + description: "Browse scheduled prompts and cancel one", + template: listTemplate, + }, + { + name: "schedule-cancel", + description: "Cancel a scheduled prompt by id, or all of them", + template: cancelTemplate, + }, +] + +/** Pre-rename names, kept so existing habits and docs keep working. They + * render exactly the same templates as their canonical counterparts. */ +export const legacy: ReadonlyArray = [ { name: "wait", - description: "Send a prompt to this session after a delay, e.g. /wait 1hour do it", - template: [ - "The user wants a prompt delivered later, not now.", - "", - "Call the `wait_schedule` tool exactly once with:", - "", - "- `duration`: $1", - "- `prompt`: the text between the OPENCODE_WAIT_PROMPT markers below, copied verbatim,", - " without the marker lines", - "", - "OPENCODE_WAIT_PROMPT", - "$2", - "OPENCODE_WAIT_PROMPT", - "", - "Do not start the requested work, do not read any files, and do not call any other tool.", - "After `wait_schedule` returns, reply with one short line repeating what it reported.", - ].join("\n"), + description: "Alias of /schedule: send a prompt after a delay, e.g. /wait 1hour do it", + template: scheduleTemplate, }, { name: "wait-list", - description: "Browse pending scheduled prompts and cancel one", - template: [ - "Call the `wait_list` tool exactly once and do not call any other tool.", - "Reply with its output and nothing else.", - ].join("\n"), + description: "Alias of /schedule-list: browse scheduled prompts", + template: listTemplate, }, { name: "wait-cancel", - description: "Cancel a scheduled prompt by id, or all of them", - template: [ - "Call the `wait_cancel` tool exactly once and do not call any other tool.", - "", - "The user wrote: $ARGUMENTS", - "", - "If that text is `all`, call the tool with `all` set to true.", - "Otherwise call the tool with `id` set to that text exactly.", - "If the text is empty, call the tool with no arguments.", - "Reply with the tool's output and nothing else.", - ].join("\n"), + description: "Alias of /schedule-cancel: cancel a scheduled prompt by id, or all", + template: cancelTemplate, }, ] +export const definitions: ReadonlyArray = [...canonical, ...legacy] + /** * The slice of the OpenCode session API a command needs to submit its * template, kept narrow like `Delivery.SessionPort` so the server plugin @@ -106,7 +137,7 @@ const arity = (template: string): number => { } /** Splits on whitespace until one placeholder is left, which takes everything - * that remains: `/wait 1hour do the thing` gives `$1` the duration and `$2` + * that remains: `/schedule 1hour do the thing` gives `$1` the duration and `$2` * the whole prompt, spaces included. */ const positional = (args: string, count: number): ReadonlyArray => { const parts: Array = [] diff --git a/src/delivery.ts b/src/server/delivery.ts similarity index 91% rename from src/delivery.ts rename to src/server/delivery.ts index 2a0fe60..80c48be 100644 --- a/src/delivery.ts +++ b/src/server/delivery.ts @@ -1,6 +1,6 @@ import type { Session } from "@opencode/schema/session" import { Context, Effect, Layer } from "effect" -import type { Wait } from "./domain.ts" +import type { Wait } from "../shared/domain.ts" import type { Delivery as Mode, Resolved } from "./options.ts" /** @@ -46,7 +46,7 @@ export const layer = (session: SessionPort, options: Resolved): Layer.Layer - Effect.logError(`opencode-waits: failed to deliver wait ${wait.id}`, cause).pipe( + Effect.logError(`opencode-schedule: failed to deliver wait ${wait.id}`, cause).pipe( Effect.as(false), ), ), diff --git a/src/gui-server.ts b/src/server/gui-server.ts similarity index 96% rename from src/gui-server.ts rename to src/server/gui-server.ts index 453e1b9..c2f6eb9 100644 --- a/src/gui-server.ts +++ b/src/server/gui-server.ts @@ -1,9 +1,9 @@ import type { Plugin } from "@opencode/plugin/effect" import type { Session } from "@opencode/schema/session" import { Effect, Option, type Scope } from "effect" -import type { Wait } from "./domain.ts" -import * as WaitDuration from "./duration.ts" -import { type WaitRow, WaitsRpc } from "./rpc.ts" +import type { Wait } from "../shared/domain.ts" +import * as WaitDuration from "../shared/duration.ts" +import { type WaitRow, WaitsRpc } from "../shared/rpc.ts" import type { Interface as Scheduler, SessionProbe } from "./supervisor.ts" /** A declared `schedule` error, mirroring `WaitsRpc.methods.schedule.errors`. */ diff --git a/src/index.ts b/src/server/index.ts similarity index 95% rename from src/index.ts rename to src/server/index.ts index 5ed7497..59bec35 100644 --- a/src/index.ts +++ b/src/server/index.ts @@ -1,13 +1,13 @@ import { watch } from "node:fs" import { Plugin } from "@opencode/plugin/effect" import { Context, Effect, Layer } from "effect" +import * as Node from "../shared/node.ts" +import { WaitsRpc } from "../shared/rpc.ts" +import * as Store from "../shared/store.ts" import * as Commands from "./commands.ts" import * as Delivery from "./delivery.ts" import * as GuiServer from "./gui-server.ts" -import * as Node from "./node.ts" import * as Options from "./options.ts" -import { WaitsRpc } from "./rpc.ts" -import * as Store from "./store.ts" import * as Supervisor from "./supervisor.ts" import * as Tools from "./tools.ts" diff --git a/src/options.ts b/src/server/options.ts similarity index 95% rename from src/options.ts rename to src/server/options.ts index c0fb28d..6d084cd 100644 --- a/src/options.ts +++ b/src/server/options.ts @@ -39,7 +39,7 @@ export const resolve = (input: unknown): Effect.Effect => const decoded = decodeOption(input ?? {}) if (Option.isNone(decoded)) { yield* Effect.logWarning( - "opencode-waits: ignoring invalid plugin options, using defaults", + "opencode-schedule: ignoring invalid plugin options, using defaults", input, ) return { delivery: defaults.delivery } diff --git a/src/supervisor.ts b/src/server/supervisor.ts similarity index 92% rename from src/supervisor.ts rename to src/server/supervisor.ts index 776c679..e74bf73 100644 --- a/src/supervisor.ts +++ b/src/server/supervisor.ts @@ -1,8 +1,8 @@ import type { Session } from "@opencode/schema/session" import { Clock, Duration, Effect, FiberMap, Option, Ref, type Scope } from "effect" +import type { Wait, WaitID } from "../shared/domain.ts" +import * as Store from "../shared/store.ts" import * as Delivery from "./delivery.ts" -import type { Wait, WaitID } from "./domain.ts" -import * as Store from "./store.ts" export interface ScheduleInput { readonly sessionID: Session.ID @@ -56,7 +56,7 @@ export const make = ( Ref.update(armed, (ids) => new Set([...ids].filter((x) => x !== id))) const logStoreFailure = (operation: string) => (error: Store.StoreError) => - Effect.logError(`opencode-waits: ${operation}: ${error.display}`) + Effect.logError(`opencode-schedule: ${operation}: ${error.display}`) const load = store.list.pipe( Effect.catch((error) => @@ -83,7 +83,7 @@ export const make = ( Effect.tap((owned) => owned ? Effect.void - : Effect.logInfo(`opencode-waits: ${wait.id} was already claimed elsewhere`), + : Effect.logInfo(`opencode-schedule: ${wait.id} was already claimed elsewhere`), ), // Conservative: a store that cannot be read must never risk a // second delivery, so an unknown failure counts as not owned. @@ -102,7 +102,7 @@ export const make = ( if (!alive) { // The claim already removed the record, so there is nothing to drop. yield* Effect.logInfo( - `opencode-waits: dropping ${wait.id}, session ${wait.sessionID} is gone`, + `opencode-schedule: dropping ${wait.id}, session ${wait.sessionID} is gone`, ) return } @@ -114,13 +114,13 @@ export const make = ( const retry = Store.nextAttempt(wait, now) if (retry === undefined) { yield* Effect.logError( - `opencode-waits: giving up on ${wait.id} after ${wait.attempts} attempts`, + `opencode-schedule: giving up on ${wait.id} after ${wait.attempts} attempts`, ) return } yield* Effect.logWarning( - `opencode-waits: delivery of ${wait.id} failed, re-arming attempt ${retry.attempts}`, + `opencode-schedule: delivery of ${wait.id} failed, re-arming attempt ${retry.attempts}`, ) yield* store.update(retry).pipe(Effect.catch(logStoreFailure("could not re-arm wait"))) yield* forget(wait.id) @@ -138,7 +138,7 @@ export const make = ( // that matured while OpenCode was not running. const delay = Math.max(0, wait.firesAt - now) if (delay > 0 && wait.firesAt < now) { - yield* Effect.logInfo(`opencode-waits: ${wait.id} is overdue, firing now`) + yield* Effect.logInfo(`opencode-schedule: ${wait.id} is overdue, firing now`) } yield* FiberMap.run( diff --git a/src/tools.ts b/src/server/tools.ts similarity index 55% rename from src/tools.ts rename to src/server/tools.ts index 54aac0e..62a8807 100644 --- a/src/tools.ts +++ b/src/server/tools.ts @@ -1,7 +1,7 @@ import { Tool } from "@opencode/schema/tool" import { Clock, Duration, Effect, Option, Schema } from "effect" -import type { Wait } from "./domain.ts" -import * as WaitDuration from "./duration.ts" +import type { Wait } from "../shared/domain.ts" +import * as WaitDuration from "../shared/duration.ts" import type { Interface as Scheduler } from "./supervisor.ts" /** `now` is passed in rather than read from `Date`, so it comes from the same @@ -10,12 +10,30 @@ const describe = (wait: Wait, now: number): string => `${wait.id} in ${Duration.format(Duration.millis(Math.max(0, wait.firesAt - now)))}` + ` (at ${new Date(wait.firesAt).toISOString()}) ${JSON.stringify(wait.prompt)}` -const scheduleTool = (scheduler: Scheduler) => +/** Canonical tool names. The `wait_*` names registered alongside them are + * compatibility aliases for sessions whose history still refers to them. */ +export const names = { + schedule: "schedule_create", + list: "schedule_list", + cancel: "schedule_cancel", +} as const + +export const legacyNames = { + schedule: "wait_schedule", + list: "wait_list", + cancel: "wait_cancel", +} as const + +const aliasNote = (canonical: string): string => + ` Compatibility alias of ${canonical}; prefer ${canonical}.` + +const scheduleTool = (scheduler: Scheduler, name: string, note = "") => ({ - name: "wait_schedule", + name, description: "Schedule a prompt to be sent back to this session after a delay. Returns immediately; " + - "the prompt is delivered later without any further action. Use this instead of sleeping.", + "the prompt is delivered later without any further action. Use this instead of sleeping." + + note, input: Schema.Struct({ duration: Schema.String.annotate({ description: 'How long to wait, e.g. "60s", "1hour", "2h30m", "3 days".', @@ -25,12 +43,13 @@ const scheduleTool = (scheduler: Scheduler) => }), }), options: { codemode: false }, - execute: Effect.fn("wait_schedule")(function* (input, context) { + execute: Effect.fn(name)(function* (input, context) { const prompt = input.prompt.trim() if (prompt === "") { return yield* Effect.fail( new Tool.Error({ - message: "Nothing to schedule: a prompt is required, e.g. /wait 1hour implement this", + message: + "Nothing to schedule: a prompt is required, e.g. /schedule 1hour implement this", }), ) } @@ -49,56 +68,63 @@ const scheduleTool = (scheduler: Scheduler) => content: `Scheduled ${wait.id}: this prompt will be sent in ${Duration.format(duration)} ` + `(at ${new Date(wait.firesAt).toISOString()}). ` + - `Cancel with /wait-cancel ${wait.id}. Pending waits are lost if OpenCode restarts.`, + `Cancel with /schedule-cancel ${wait.id}.` + + // A `mem-` id means the store could not persist it; it still fires + // in this process but will not survive a restart. + (wait.id.startsWith("mem-") + ? " It could not be saved, so it will be lost if OpenCode restarts." + : " It is saved and survives an OpenCode restart."), } }), }) satisfies Tool.Info -const listTool = (scheduler: Scheduler) => +const listTool = (scheduler: Scheduler, name: string, note = "") => ({ - name: "wait_list", - description: "List the prompts scheduled by wait_schedule that are still pending.", + name, + description: `List the prompts scheduled by ${names.schedule} that are still pending.${note}`, // Declared as raw JSON Schema rather than `Schema.Struct({})`, which // generates `anyOf: [object, array]` with no top-level `type`. Anthropic // rejects every request in the session when a tool schema omits `type`. input: { type: "object", properties: {}, additionalProperties: false }, options: { codemode: false }, - execute: Effect.fn("wait_list")(function* (_input: unknown, context) { + execute: Effect.fn(name)(function* (_input: unknown, context) { const waits = yield* scheduler.list(context.sessionID) - if (waits.length === 0) return { content: "No pending waits in this session." } + if (waits.length === 0) return { content: "No pending scheduled prompts in this session." } const now = yield* Clock.currentTimeMillis return { content: [ - `${waits.length} pending wait(s):`, + `${waits.length} pending scheduled prompt(s):`, ...waits.map((wait) => describe(wait, now)), ].join("\n"), } }), }) satisfies Tool.Info -const cancelTool = (scheduler: Scheduler) => +const cancelTool = (scheduler: Scheduler, name: string, note = "") => ({ - name: "wait_cancel", - description: "Cancel a pending wait by id, or every pending wait in this session.", + name, + description: `Cancel a scheduled prompt by id, or every scheduled prompt in this session.${note}`, input: Schema.Struct({ id: Schema.optional( - Schema.String.annotate({ description: 'Id returned by wait_schedule, e.g. "w1".' }), + Schema.String.annotate({ description: `Id returned by ${names.schedule}, e.g. "w1".` }), ), all: Schema.optional( - Schema.Boolean.annotate({ description: "Cancel every pending wait in this session." }), + Schema.Boolean.annotate({ + description: "Cancel every scheduled prompt in this session.", + }), ), }), options: { codemode: false }, - execute: Effect.fn("wait_cancel")(function* (input, context) { + execute: Effect.fn(name)(function* (input, context) { // `Schema.optional` renders as `anyOf: [string, null]`, so a provider may // legitimately send null rather than omitting the field. const id = typeof input.id === "string" ? input.id.trim() : "" if (input.all === true) { const cancelled = yield* scheduler.cancelAll(context.sessionID) - if (cancelled.length === 0) return { content: "No pending waits to cancel." } + if (cancelled.length === 0) return { content: "No pending scheduled prompts to cancel." } return { - content: `Cancelled ${cancelled.length} wait(s): ${cancelled.map((wait) => wait.id).join(", ")}`, + content: `Cancelled ${cancelled.length} scheduled prompt(s): ${cancelled.map((wait) => wait.id).join(", ")}`, } } @@ -108,8 +134,8 @@ const cancelTool = (scheduler: Scheduler) => new Tool.Error({ message: waits.length === 0 - ? "No pending waits in this session." - : `Specify which wait to cancel, or pass all: true. Pending: ${waits + ? "No pending scheduled prompts in this session." + : `Specify which scheduled prompt to cancel, or pass all: true. Pending: ${waits .map((wait) => wait.id) .join(", ")}`, }), @@ -121,7 +147,7 @@ const cancelTool = (scheduler: Scheduler) => const waits = yield* scheduler.list(context.sessionID) return yield* Effect.fail( new Tool.Error({ - message: `No pending wait ${JSON.stringify(id)} in this session.${ + message: `No pending scheduled prompt ${JSON.stringify(id)} in this session.${ waits.length === 0 ? "" : ` Pending: ${waits.map((wait) => wait.id).join(", ")}` }`, }), @@ -134,8 +160,13 @@ const cancelTool = (scheduler: Scheduler) => }), }) satisfies Tool.Info +/** Canonical tools first, then the `wait_*` compatibility aliases, which + * share the same executors and input schemas. */ export const all = (scheduler: Scheduler): ReadonlyArray => [ - scheduleTool(scheduler), - listTool(scheduler), - cancelTool(scheduler), + scheduleTool(scheduler, names.schedule), + listTool(scheduler, names.list), + cancelTool(scheduler, names.cancel), + scheduleTool(scheduler, legacyNames.schedule, aliasNote(names.schedule)), + listTool(scheduler, legacyNames.list, aliasNote(names.list)), + cancelTool(scheduler, legacyNames.cancel, aliasNote(names.cancel)), ] diff --git a/src/domain.ts b/src/shared/domain.ts similarity index 100% rename from src/domain.ts rename to src/shared/domain.ts diff --git a/src/duration.ts b/src/shared/duration.ts similarity index 100% rename from src/duration.ts rename to src/shared/duration.ts diff --git a/src/node.ts b/src/shared/node.ts similarity index 100% rename from src/node.ts rename to src/shared/node.ts diff --git a/src/rpc.ts b/src/shared/rpc.ts similarity index 100% rename from src/rpc.ts rename to src/shared/rpc.ts diff --git a/src/store.ts b/src/shared/store.ts similarity index 100% rename from src/store.ts rename to src/shared/store.ts diff --git a/src/tui.ts b/src/tui/index.ts similarity index 89% rename from src/tui.ts rename to src/tui/index.ts index 6d55f4e..28b83a0 100644 --- a/src/tui.ts +++ b/src/tui/index.ts @@ -1,11 +1,11 @@ import { Plugin } from "@opencode/plugin/tui" import { Duration, Effect } from "effect" -import type { Wait } from "./domain.ts" -import * as WaitDuration from "./duration.ts" +import type { Wait } from "../shared/domain.ts" +import * as WaitDuration from "../shared/duration.ts" +import * as Node from "../shared/node.ts" +import * as Store from "../shared/store.ts" import { Manager, type ManagerRow } from "./manager.tsx" -import * as Node from "./node.ts" import { type Row, Waits } from "./sidebar.tsx" -import * as Store from "./store.ts" type Action = "send" | "delete" | "edit" | "reschedule" @@ -52,7 +52,7 @@ export default Plugin.define({ Duration.format(Duration.seconds(Math.max(0, Math.round((firesAt - now) / 1000)))) const fail = (message: string) => - ctx.ui.toast.show({ message, variant: "error", title: "Waits" }) + ctx.ui.toast.show({ message, variant: "error", title: "Schedules" }) const schedule = async (input?: string) => { // The palette invokes commands with no input, so ask for it rather than @@ -60,7 +60,7 @@ export default Plugin.define({ let text = (input ?? "").trim() if (text === "") { const answer = await ctx.ui.dialog.prompt({ - title: "Schedule a wait", + title: "Schedule a prompt", description: "A delay, then the prompt to send once it elapses", placeholder: "1hour implement this", }) @@ -71,7 +71,9 @@ export default Plugin.define({ const split = text.indexOf(" ") if (split < 1) { - return void fail("Usage: /wait , e.g. /wait 1hour implement this") + return void fail( + "Usage: /schedule , e.g. /schedule 1hour implement this", + ) } const parsed = await Effect.runPromise( @@ -99,9 +101,9 @@ export default Plugin.define({ }), ) ctx.ui.toast.show({ - title: "Waits", + title: "Schedules", variant: "success", - message: `Scheduled ${wait.id} in ${Duration.format(duration)}. Cancel with /wait-cancel ${wait.id}.`, + message: `Scheduled ${wait.id} in ${Duration.format(duration)}. Cancel with /schedule-cancel ${wait.id}.`, }) } catch (cause) { fail(`Could not save the wait: ${cause instanceof Error ? cause.message : String(cause)}`) @@ -179,10 +181,10 @@ export default Plugin.define({ ctx.ui.dialog.show( () => Manager({ - title: `Waits (${manager.rows.length})`, + title: `Schedules (${manager.rows.length})`, rows: visible(), index: manager.index, - empty: manager.query.trim() === "" ? "No pending waits." : "No results found", + empty: manager.query.trim() === "" ? "No scheduled prompts." : "No results found", actions: [ { title: "actions", label: "enter" }, { title: "add", label: "ctrl+a" }, @@ -221,7 +223,7 @@ export default Plugin.define({ if (row === undefined) return await run(store.remove(row.id)) await refreshManager() - ctx.ui.toast.show({ title: "Waits", variant: "success", message: `Cancelled ${row.id}.` }) + ctx.ui.toast.show({ title: "Schedules", variant: "success", message: `Cancelled ${row.id}.` }) } /** ctrl+e: edit the highlighted wait's prompt, then return to the list. */ @@ -238,7 +240,11 @@ export default Plugin.define({ }) if (edited !== undefined && edited.trim() !== "") { await run(store.update({ ...wait, prompt: edited.trim() })) - ctx.ui.toast.show({ title: "Waits", variant: "success", message: `Updated ${wait.id}.` }) + ctx.ui.toast.show({ + title: "Schedules", + variant: "success", + message: `Updated ${wait.id}.`, + }) } await openMenu() } @@ -270,7 +276,7 @@ export default Plugin.define({ const done = (message: string) => { ctx.ui.dialog.clear() - ctx.ui.toast.show({ title: "Waits", variant: "success", message }) + ctx.ui.toast.show({ title: "Schedules", variant: "success", message }) } if (action === "delete") { @@ -297,7 +303,7 @@ export default Plugin.define({ if (edited === undefined || edited.trim() === "") return await run(store.update({ ...wait, prompt: edited.trim() })) return void ctx.ui.toast.show({ - title: "Waits", + title: "Schedules", variant: "success", message: `Updated ${wait.id}.`, }) @@ -320,7 +326,7 @@ export default Plugin.define({ const at = Date.now() + Duration.toMillis(parsed.duration) await run(store.update({ ...wait, duration: parsed.duration, firesAt: at, attempts: 0 })) ctx.ui.toast.show({ - title: "Waits", + title: "Schedules", variant: "success", message: `${wait.id} now fires in ${Duration.format(parsed.duration)}.`, }) @@ -339,7 +345,7 @@ export default Plugin.define({ if (target === "all") { for (const wait of mine) await run(store.remove(wait.id)) return void ctx.ui.toast.show({ - title: "Waits", + title: "Schedules", message: mine.length === 0 ? "No pending waits to cancel." : `Cancelled ${mine.length}.`, }) } @@ -353,7 +359,11 @@ export default Plugin.define({ ) } await run(store.remove(found.id)) - ctx.ui.toast.show({ title: "Waits", variant: "success", message: `Cancelled ${found.id}.` }) + ctx.ui.toast.show({ + title: "Schedules", + variant: "success", + message: `Cancelled ${found.id}.`, + }) } // Mirrors the pending waits into host-owned reactive state. `storage.memory` @@ -386,7 +396,7 @@ export default Plugin.define({ render: () => Waits({ get title() { - return `Waits (${mirror.rows.length})` + return `Schedules (${mirror.rows.length})` }, get rows() { return mirror.rows @@ -419,36 +429,36 @@ export default Plugin.define({ commands: [ { id: "waits.manager.down", - title: "Next wait", - group: "Waits", + title: "Next scheduled prompt", + group: "Schedules", bind: "down", run: () => move(1), }, { id: "waits.manager.up", - title: "Previous wait", - group: "Waits", + title: "Previous scheduled prompt", + group: "Schedules", bind: "up", run: () => move(-1), }, { id: "waits.manager.add", title: "Schedule a new wait", - group: "Waits", + group: "Schedules", bind: "ctrl+a", run: () => void quickAdd(), }, { id: "waits.manager.delete", title: "Delete the highlighted wait", - group: "Waits", + group: "Schedules", bind: "ctrl+d", run: () => void quickDelete(), }, { id: "waits.manager.edit", title: "Edit the highlighted wait", - group: "Waits", + group: "Schedules", bind: "ctrl+e", run: () => void quickEdit(), }, @@ -464,30 +474,30 @@ export default Plugin.define({ commands: [ { id: "waits.schedule", - title: "Schedule a wait", + title: "Schedule a prompt", description: "Send a prompt to this session after a delay, e.g. /wait 1hour do it", - group: "Waits", + group: "Schedules", palette: true, - slash: { name: "wait", arguments: true }, + slash: { name: "schedule", aliases: ["wait"], arguments: true }, run: (input) => void schedule(input), }, { id: "waits.list", title: "View waits", description: "Browse pending scheduled prompts and cancel one", - group: "Waits", + group: "Schedules", palette: true, - slash: { name: "wait-list", aliases: ["waits"] }, + slash: { name: "schedule-list", aliases: ["wait-list", "waits"] }, run: () => void list(), }, { id: "waits.cancel", title: "Cancel a wait", description: "Cancel a scheduled prompt by id, or all of them", - group: "Waits", + group: "Schedules", // Deliberately not in the palette: it needs an id, and the // palette cannot supply one. View waits offers Delete instead. - slash: { name: "wait-cancel", arguments: true }, + slash: { name: "schedule-cancel", aliases: ["wait-cancel"], arguments: true }, run: (input) => void cancel(input), }, ], diff --git a/src/manager.tsx b/src/tui/manager.tsx similarity index 100% rename from src/manager.tsx rename to src/tui/manager.tsx diff --git a/src/sidebar.tsx b/src/tui/sidebar.tsx similarity index 96% rename from src/sidebar.tsx rename to src/tui/sidebar.tsx index e974ac0..01dc737 100644 --- a/src/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -13,7 +13,7 @@ const muted = "#565f89" const normal = "#c0caf5" /** - * The pending waits, rendered into the session sidebar. + * The scheduled prompts, rendered into the session sidebar. * * Nothing here imports `solid-js`. The plugin would resolve its own copy, * which need not be the same instance the TUI runs, and a mismatched instance diff --git a/test/commands.test.ts b/test/commands.test.ts index 1d22c65..72c4213 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Session } from "@opencode/schema/session" import { Skill } from "@opencode/schema/skill" import { Cause, Effect, Exit, Schema } from "effect" -import * as Commands from "../src/commands.ts" +import * as Commands from "../src/server/commands.ts" type PromptCall = Parameters[0] @@ -37,14 +37,14 @@ const invocation = ( 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") + const rendered = Commands.render(definition("schedule").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 ") + const rendered = Commands.render(definition("schedule").template, " 1hour do it ") expect(rendered).toContain("- `duration`: 1hour") expect(rendered).toContain("\nOPENCODE_WAIT_PROMPT\ndo it\nOPENCODE_WAIT_PROMPT") @@ -55,13 +55,13 @@ describe("Commands.render", () => { // 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}`) + const rendered = Commands.render(definition("schedule").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") + const rendered = Commands.render(definition("schedule").template, "1hour") expect(rendered).toContain("- `duration`: 1hour") expect(rendered).toContain("\nOPENCODE_WAIT_PROMPT\n\nOPENCODE_WAIT_PROMPT") @@ -69,7 +69,7 @@ describe("Commands.render", () => { }) test("renders every placeholder as empty when no arguments were given", () => { - const rendered = Commands.render(definition("wait").template, "") + const rendered = Commands.render(definition("schedule").template, "") expect(rendered).toContain("- `duration`: \n") expect(rendered).not.toContain("$1") @@ -77,7 +77,7 @@ describe("Commands.render", () => { }) test("gives $ARGUMENTS the whole cancellation argument", () => { - const template = definition("wait-cancel").template + const template = definition("schedule-cancel").template expect(Commands.render(template, " all ")).toContain("The user wrote: all") expect(Commands.render(template, "w7")).toContain("The user wrote: w7") @@ -85,7 +85,7 @@ describe("Commands.render", () => { }) test("leaves a template without placeholders untouched", () => { - const template = definition("wait-list").template + const template = definition("schedule-list").template expect(Commands.render(template, "ignored input")).toBe(template) }) @@ -105,7 +105,7 @@ describe("Commands.command", () => { test("submits the rendered template to the invoking session with its delivery", async () => { const { session, calls } = stubSession() - const wait = definition("wait") + const wait = definition("schedule") const command = Commands.command(wait, session) const input = invocation("1hour do it", { delivery: "steer" }) @@ -125,7 +125,7 @@ describe("Commands.command", () => { 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) + const command = Commands.command(definition("schedule"), session) await Effect.runPromise( command.execute( @@ -142,7 +142,7 @@ describe("Commands.command", () => { 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 command = Commands.command(definition("schedule-list"), session) const exit = await Effect.runPromiseExit(command.execute(invocation(""))) @@ -151,3 +151,64 @@ describe("Commands.command", () => { expect(Cause.squash(exit.cause)).toBe("submission rejected") }) }) + +describe("Commands.definitions naming", () => { + const pairs = [ + ["schedule", "wait", "schedule_create"], + ["schedule-list", "wait-list", "schedule_list"], + ["schedule-cancel", "wait-cancel", "schedule_cancel"], + ] as const + + test("registers the canonical names and the legacy aliases, with no duplicates", () => { + const names = Commands.definitions.map((candidate) => candidate.name) + + expect(new Set(names).size).toBe(names.length) + expect(names).toEqual([ + "schedule", + "schedule-list", + "schedule-cancel", + "wait", + "wait-list", + "wait-cancel", + ]) + }) + + for (const [canonical, legacy, tool] of pairs) { + test(`/${legacy} renders exactly what /${canonical} does, calling ${tool}`, () => { + const current = definition(canonical) + const alias = definition(legacy) + + expect(alias.template).toBe(current.template) + expect(current.template).toContain(`\`${tool}\``) + expect(current.template).not.toMatch(/`wait_(schedule|list|cancel)`/) + for (const args of ["", "1hour do it", " all ", "w7"]) { + expect(Commands.render(alias.template, args)).toBe(Commands.render(current.template, args)) + } + }) + } + + test("canonical descriptions use the schedule wording", () => { + expect(definition("schedule").description).toContain("/schedule 1hour") + for (const name of ["schedule", "schedule-list", "schedule-cancel"]) { + expect(definition(name).description.toLowerCase()).not.toContain("wait") + } + }) + + test("a legacy alias submits the same prompt as its canonical command", async () => { + const { session, calls } = stubSession() + const sessionID = Session.ID.create() + + for (const name of ["schedule", "wait"]) { + await Effect.runPromise( + Commands.command(definition(name), session).execute( + invocation("2h30m ship it", { sessionID, delivery: "queue" }), + ), + ) + } + + expect(calls).toHaveLength(2) + expect(calls[1]).toEqual(calls[0]) + expect(calls[0]?.text).toContain("- `duration`: 2h30m") + expect(calls[0]?.text).toContain("\nOPENCODE_WAIT_PROMPT\nship it\nOPENCODE_WAIT_PROMPT") + }) +}) diff --git a/test/duration.test.ts b/test/duration.test.ts index fe26204..3bc76b1 100644 --- a/test/duration.test.ts +++ b/test/duration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Duration, Effect, Result } from "effect" -import { type InvalidDuration, maximum, parse } from "../src/duration.ts" +import { type InvalidDuration, maximum, parse } from "../src/shared/duration.ts" /** Runs `parse` and returns the millisecond value of a successful parse. */ const millisOf = (input: string): number => { diff --git a/test/gui-server.test.ts b/test/gui-server.test.ts index b36eced..ec14ee3 100644 --- a/test/gui-server.test.ts +++ b/test/gui-server.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test" import { Session } from "@opencode/schema/session" import { Duration, Effect, Layer, Queue, type Scope } from "effect" import { TestClock } from "effect/testing" -import * as Delivery from "../src/delivery.ts" -import type { Wait } from "../src/domain.ts" -import * as GuiServer from "../src/gui-server.ts" -import { WaitsRpc } from "../src/rpc.ts" -import * as Store from "../src/store.ts" -import * as Supervisor from "../src/supervisor.ts" +import * as Delivery from "../src/server/delivery.ts" +import * as GuiServer from "../src/server/gui-server.ts" +import * as Supervisor from "../src/server/supervisor.ts" +import type { Wait } from "../src/shared/domain.ts" +import { WaitsRpc } from "../src/shared/rpc.ts" +import * as Store from "../src/shared/store.ts" const root = "/waits" diff --git a/test/gui.test.ts b/test/gui.test.ts index b1d504b..5229230 100644 --- a/test/gui.test.ts +++ b/test/gui.test.ts @@ -6,7 +6,7 @@ import { errorMessage, parseWaitArguments, remaining, -} from "../src/gui-utils.ts" +} from "../src/gui/utils.ts" // Bun shares one module cache across test files. `@opencode/plugin/gui` pulls // in solid-js, and without OpenTUI's plugin Bun resolves its server build, @@ -14,7 +14,7 @@ import { // plugin those tests use before loading the GUI SDK (see sidebar.test.ts). ensureSolidTransformPlugin() const { Command } = await import("@opencode/plugin/gui") -const { registerWaitCommands } = await import("../src/gui-commands.ts") +const { registerWaitCommands } = await import("../src/gui/commands.ts") function commandHarness() { const commands = new Map() @@ -164,16 +164,42 @@ describe("GUI wait helpers", () => { test("registers actual local palette/slash commands without submitting a session prompt", async () => { const harness = commandHarness() - expect([...harness.commands.keys()].sort()).toEqual(["cancel", "list", "open", "wait"]) - expect(harness.commands.get("wait")?.slash).toEqual({ name: "wait", arguments: true }) - expect(harness.commands.get("cancel")?.slash).toEqual({ name: "wait-cancel", arguments: true }) - expect(harness.commands.get("list")?.slash).toEqual({ name: "wait-list", arguments: true }) + expect([...harness.commands.keys()].sort()).toEqual([ + "cancel", + "legacy-wait", + "legacy-wait-cancel", + "legacy-wait-list", + "list", + "open", + "wait", + ]) + expect(harness.commands.get("wait")?.slash).toEqual({ name: "schedule", arguments: true }) + expect(harness.commands.get("cancel")?.slash).toEqual({ + name: "schedule-cancel", + arguments: true, + }) + expect(harness.commands.get("list")?.slash).toEqual({ name: "schedule-list", arguments: true }) + expect(harness.commands.get("legacy-wait")?.slash).toEqual({ name: "wait", arguments: true }) + expect(harness.commands.get("legacy-wait-list")?.slash).toEqual({ + name: "wait-list", + arguments: true, + }) + expect(harness.commands.get("legacy-wait-cancel")?.slash).toEqual({ + name: "wait-cancel", + arguments: true, + }) harness.setSession("ses-current") await harness.commands.get("wait")?.run("2hours send the summary back") expect(harness.calls).toEqual(["schedule:ses-current:2hours:send the summary back"]) expect(harness.prompts).toEqual([]) - expect(harness.notices.at(-1)?.title).toBe("Wait scheduled") + expect(harness.notices.at(-1)?.title).toBe("Prompt scheduled") + await harness.commands.get("legacy-wait")?.run("2hours send the summary back") + expect(harness.calls.at(-1)).toBe("schedule:ses-current:2hours:send the summary back") + await harness.commands.get("legacy-wait-list")?.run() + expect(harness.opened.at(-1)).toBe("opencode2-waits:main") + await harness.commands.get("legacy-wait-cancel")?.run("w123") + expect(harness.calls.at(-1)).toBe("cancel:ses-current:w123") }) test("handles invalid and sessionless /wait without making an RPC call", async () => { @@ -195,18 +221,18 @@ describe("GUI wait helpers", () => { await harness.commands.get("cancel")?.run("all") expect(harness.calls).toEqual(["cancel:ses-one:w123", "cancelAll:ses-one"]) expect(harness.notices.map(({ title }) => title)).toEqual([ - "Wait cancelled.", - "2 waits cancelled.", + "Scheduled prompt cancelled.", + "2 scheduled prompts cancelled.", ]) harness.failCancel() await harness.commands.get("cancel")?.run("w123") - expect(harness.notices.at(-1)?.title).toBe("Could not cancel wait") + expect(harness.notices.at(-1)?.title).toBe("Could not cancel scheduled prompt") expect(harness.notices.at(-1)?.description).toBe("server rejected cancel") harness.failSchedule() await harness.commands.get("wait")?.run("1m try again") - expect(harness.notices.at(-1)?.title).toBe("Could not schedule wait") + expect(harness.notices.at(-1)?.title).toBe("Could not schedule prompt") expect(harness.notices.at(-1)?.description).toBe("server rejected schedule") }) @@ -221,7 +247,7 @@ describe("GUI wait helpers", () => { await harness.commands.get("wait")?.run("soon do it") expect(harness.calls).toEqual(["schedule:ses-typed:soon:do it"]) expect(harness.notices.at(-1)).toEqual({ - title: "Could not schedule wait", + title: "Could not schedule prompt", description: 'Invalid duration "soon": expected a number at "soon"', variant: "error", }) diff --git a/test/options.test.ts b/test/options.test.ts index e16c531..dc99bfc 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { defaults, resolve } from "../src/options.ts" +import { defaults, resolve } from "../src/server/options.ts" const resolveSync = (input: unknown) => Effect.runSync(resolve(input)) diff --git a/test/sidebar-plugin.test.ts b/test/sidebar-plugin.test.ts index 685faf3..e78abcd 100644 --- a/test/sidebar-plugin.test.ts +++ b/test/sidebar-plugin.test.ts @@ -5,16 +5,16 @@ import { join } from "node:path" import type { Plugin } from "@opencode/plugin/tui" import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin" import { Duration, Effect } from "effect" -import type { Wait } from "../src/domain.ts" -import * as Node from "../src/node.ts" -import * as Store from "../src/store.ts" +import type { Wait } from "../src/shared/domain.ts" +import * as Node from "../src/shared/node.ts" +import * as Store from "../src/shared/store.ts" // See sidebar.test.ts: Solid JSX needs OpenTUI's transform, registered before -// the transformed modules (`tui.ts` pulls in `sidebar.tsx`) are loaded. +// the transformed modules (`tui/index.ts` pulls in `tui/sidebar.tsx`) are loaded. ensureSolidTransformPlugin() const { testRender } = await import("@opentui/solid") const { createStore, produce } = await import("solid-js/store") -const { default: plugin } = await import("../src/tui.ts") +const { default: plugin } = await import("../src/tui/index.ts") type Context = Plugin.Context type Mounted = Awaited> @@ -194,7 +194,7 @@ describe("Waits sidebar plugin", () => { const setup = await activate(makeHost("ses_a")) const frame = setup.captureCharFrame() - expect(frame).toContain("▾ Waits (2)") + expect(frame).toContain("▾ Schedules (2)") expect(frame).toContain("w1 implement this") expect(frame).toContain("w2 someone else's") }) @@ -202,35 +202,35 @@ describe("Waits sidebar plugin", () => { test("is expanded with an empty header when there are no waits", async () => { const setup = await activate(makeHost("ses_a")) - expect(setup.captureCharFrame()).toContain("▾ Waits (0)") + expect(setup.captureCharFrame()).toContain("▾ Schedules (0)") }) test("collapses on a header click and re-expands on the next", async () => { await schedule({ session: "ses_a", prompt: "implement this", inMs: 3_600_000 }) const setup = await activate(makeHost("ses_a")) - const header = rowOf(setup.captureCharFrame(), "Waits (1)") + const header = rowOf(setup.captureCharFrame(), "Schedules (1)") await setup.mockMouse.click(3, header) await setup.renderOnce() let frame = setup.captureCharFrame() - expect(frame).toContain("▸ Waits (1)") + expect(frame).toContain("▸ Schedules (1)") expect(frame).not.toContain("w1") await setup.mockMouse.click(3, header) await setup.renderOnce() frame = setup.captureCharFrame() - expect(frame).toContain("▾ Waits (1)") + expect(frame).toContain("▾ Schedules (1)") expect(frame).toContain("w1 implement this") }) test("a wait scheduled after activation appears on the next refresh, expanded or collapsed", async () => { const setup = await activate(makeHost("ses_a")) - const header = rowOf(setup.captureCharFrame(), "Waits (0)") + const header = rowOf(setup.captureCharFrame(), "Schedules (0)") await schedule({ session: "ses_a", prompt: "arrived late", inMs: 60_000 }) await refresh(setup) expect(setup.captureCharFrame()).toContain("w1 arrived late") - expect(setup.captureCharFrame()).toContain("Waits (1)") + expect(setup.captureCharFrame()).toContain("Schedules (1)") expect(slotRenderCounts.get(setup)?.()).toBe(1) await setup.mockMouse.click(3, header) @@ -238,7 +238,7 @@ describe("Waits sidebar plugin", () => { await schedule({ session: "ses_a", prompt: "while collapsed", inMs: 60_000 }) await refresh(setup) let frame = setup.captureCharFrame() - expect(frame).toContain("▸ Waits (2)") + expect(frame).toContain("▸ Schedules (2)") expect(frame).not.toContain("while collapsed") expect(slotRenderCounts.get(setup)?.()).toBe(1) @@ -247,7 +247,7 @@ describe("Waits sidebar plugin", () => { await setup.mockMouse.click(3, header) await setup.renderOnce() frame = setup.captureCharFrame() - expect(frame).toContain("▾ Waits (2)") + expect(frame).toContain("▾ Schedules (2)") expect(frame).toContain("w1 arrived late") expect(frame).toContain("w2 while collapsed") }) @@ -262,7 +262,7 @@ describe("Waits sidebar plugin", () => { await refresh(setup) const frame = setup.captureCharFrame() - expect(frame).toContain("Waits (1)") + expect(frame).toContain("Schedules (1)") expect(frame).toContain("w1 first") expect(frame).not.toContain("w2") }) @@ -332,7 +332,7 @@ describe("Waits sidebar plugin", () => { await refresh(setup) const frame = setup.captureCharFrame() - expect(frame).toContain("Waits (1)") + expect(frame).toContain("Schedules (1)") expect(frame).not.toContain("w2") }) @@ -354,7 +354,7 @@ describe("Waits sidebar plugin", () => { await schedule({ session: "ses_a", prompt: "first", inMs: 60_000 }) const host = makeHost("ses_a") const setup = await activate(host) - const header = rowOf(setup.captureCharFrame(), "Waits (1)") + const header = rowOf(setup.captureCharFrame(), "Schedules (1)") await setup.mockMouse.click(3, header) await setup.renderOnce() @@ -371,7 +371,7 @@ describe("Waits sidebar plugin", () => { const host = makeHost("ses_a") const setup = await activate(host) const frame = setup.captureCharFrame() - const header = rowOf(frame, "Waits (1)") + const header = rowOf(frame, "Schedules (1)") const row = rowOf(frame, "w1") await setup.mockMouse.click(3, header) @@ -387,7 +387,7 @@ describe("Waits sidebar plugin", () => { const host = makeHost("ses_a") const setup = await activate(host) - await setup.mockMouse.click(3, rowOf(setup.captureCharFrame(), "Waits (1)")) + await setup.mockMouse.click(3, rowOf(setup.captureCharFrame(), "Schedules (1)")) await Bun.sleep(25) expect(host.selects).toEqual([]) diff --git a/test/sidebar.test.ts b/test/sidebar.test.ts index 6e448e5..600494e 100644 --- a/test/sidebar.test.ts +++ b/test/sidebar.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin" -import type { Row } from "../src/sidebar.tsx" +import type { Row } from "../src/tui/sidebar.tsx" // Bun does not compile Solid JSX on its own. Without OpenTUI's transform, // `sidebar.tsx` would evaluate its JSX expressions once and never react to a @@ -10,7 +10,7 @@ import type { Row } from "../src/sidebar.tsx" ensureSolidTransformPlugin() const { testRender } = await import("@opentui/solid") const { createStore } = await import("solid-js/store") -const { Waits } = await import("../src/sidebar.tsx") +const { Waits } = await import("../src/tui/sidebar.tsx") const rows: ReadonlyArray = [ { id: "w1", prompt: "implement this", countdown: "1h", mine: true }, @@ -45,27 +45,33 @@ const rowOf = (frame: string, text: string): number => { describe("Waits sidebar", () => { test("renders every row beneath an expanded header", async () => { const setup = await mount(() => - Waits({ title: "Waits (2)", rows, expanded: true, onOpen: () => {}, onToggle: () => {} }), + Waits({ title: "Schedules (2)", rows, expanded: true, onOpen: () => {}, onToggle: () => {} }), ) const frame = setup.captureCharFrame() - expect(frame).toContain("▾ Waits (2)") + expect(frame).toContain("▾ Schedules (2)") expect(frame).toContain("w1 implement this") expect(frame).toContain("1h") expect(frame).toContain("w2 other session") expect(frame).toContain("2m 5s") // Header first, then the rows in order. - expect(rowOf(frame, "Waits (2)")).toBeLessThan(rowOf(frame, "w1")) + expect(rowOf(frame, "Schedules (2)")).toBeLessThan(rowOf(frame, "w1")) expect(rowOf(frame, "w1")).toBeLessThan(rowOf(frame, "w2")) }) test("hides the rows and shows a right caret when collapsed", async () => { const setup = await mount(() => - Waits({ title: "Waits (2)", rows, expanded: false, onOpen: () => {}, onToggle: () => {} }), + Waits({ + title: "Schedules (2)", + rows, + expanded: false, + onOpen: () => {}, + onToggle: () => {}, + }), ) const frame = setup.captureCharFrame() - expect(frame).toContain("▸ Waits (2)") + expect(frame).toContain("▸ Schedules (2)") expect(frame).not.toContain("▾") expect(frame).not.toContain("w1") expect(frame).not.toContain("w2") @@ -73,10 +79,16 @@ describe("Waits sidebar", () => { test("renders only the header when there are no waits", async () => { const setup = await mount(() => - Waits({ title: "Waits (0)", rows: [], expanded: true, onOpen: () => {}, onToggle: () => {} }), + Waits({ + title: "Schedules (0)", + rows: [], + expanded: true, + onOpen: () => {}, + onToggle: () => {}, + }), ) - expect(setup.captureCharFrame()).toContain("▾ Waits (0)") + expect(setup.captureCharFrame()).toContain("▾ Schedules (0)") }) test("clicking the header toggles, and never opens a row", async () => { @@ -84,14 +96,14 @@ describe("Waits sidebar", () => { const opened: string[] = [] const setup = await mount(() => Waits({ - title: "Waits (2)", + title: "Schedules (2)", rows, expanded: true, onOpen: (id) => opened.push(id), onToggle: () => toggled.push("toggle"), }), ) - const header = rowOf(setup.captureCharFrame(), "Waits (2)") + const header = rowOf(setup.captureCharFrame(), "Schedules (2)") await setup.mockMouse.click(3, header) @@ -104,7 +116,7 @@ describe("Waits sidebar", () => { const opened: string[] = [] const setup = await mount(() => Waits({ - title: "Waits (2)", + title: "Schedules (2)", rows, expanded: true, onOpen: (id) => opened.push(id), @@ -124,7 +136,7 @@ describe("Waits sidebar", () => { const opened: string[] = [] const setup = await mount(() => Waits({ - title: "Waits (2)", + title: "Schedules (2)", rows, expanded: true, onOpen: (id) => opened.push(id), @@ -143,7 +155,7 @@ describe("Waits sidebar", () => { const opened: string[] = [] const setup = await mount(() => Waits({ - title: "Waits (2)", + title: "Schedules (2)", rows, expanded: true, onOpen: (id) => opened.push(id), @@ -163,7 +175,7 @@ describe("Waits sidebar", () => { const opened: string[] = [] const setup = await mount(() => Waits({ - title: "Waits (2)", + title: "Schedules (2)", rows, expanded: false, onOpen: (id) => opened.push(id), @@ -183,7 +195,7 @@ describe("Waits sidebar", () => { Waits({ // Getters, as Solid compiles reactive props, so each read is tracked. get title() { - return `Waits (${state.rows.length})` + return `Schedules (${state.rows.length})` }, get rows() { return state.rows @@ -195,21 +207,21 @@ describe("Waits sidebar", () => { onToggle: () => setState("expanded", (value) => !value), }), ) - const header = rowOf(setup.captureCharFrame(), "Waits (2)") + const header = rowOf(setup.captureCharFrame(), "Schedules (2)") expect(setup.captureCharFrame()).toContain("w1 implement this") await setup.mockMouse.click(3, header) await setup.renderOnce() const collapsed = setup.captureCharFrame() - expect(collapsed).toContain("▸ Waits (2)") + expect(collapsed).toContain("▸ Schedules (2)") expect(collapsed).not.toContain("w1") expect(collapsed).not.toContain("w2") await setup.mockMouse.click(3, header) await setup.renderOnce() const expanded = setup.captureCharFrame() - expect(expanded).toContain("▾ Waits (2)") + expect(expanded).toContain("▾ Schedules (2)") expect(expanded).toContain("w1 implement this") expect(expanded).toContain("w2 other session") }) @@ -223,7 +235,7 @@ describe("Waits sidebar", () => { Waits({ // Getters, as Solid compiles reactive props, so each read is tracked. get title() { - return `Waits (${state.rows.length})` + return `Schedules (${state.rows.length})` }, get rows() { return state.rows @@ -251,7 +263,7 @@ describe("Waits sidebar", () => { ]) await setup.renderOnce() frame = setup.captureCharFrame() - expect(frame).toContain("Waits (2)") + expect(frame).toContain("Schedules (2)") expect(frame).toContain("w2 added later") // Collapsed: rows vanish, but the title count still tracks changes. @@ -260,7 +272,7 @@ describe("Waits sidebar", () => { setState("rows", (current) => current.filter((row) => row.id !== "w1")) await setup.renderOnce() frame = setup.captureCharFrame() - expect(frame).toContain("▸ Waits (1)") + expect(frame).toContain("▸ Schedules (1)") expect(frame).not.toContain("w2") // Re-expanding shows what changed while it was collapsed, not stale rows. @@ -274,7 +286,7 @@ describe("Waits sidebar", () => { test("a long prompt does not push the countdown off its row", async () => { const setup = await mount(() => Waits({ - title: "Waits (1)", + title: "Schedules (1)", rows: [{ id: "w9", prompt: "a".repeat(20), countdown: "3h", mine: true }], expanded: true, onOpen: () => {}, diff --git a/test/store.test.ts b/test/store.test.ts index 4e80958..f3e55ea 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "bun:test" import { Session } from "@opencode/schema/session" import { Duration, Effect } from "effect" -import type { Wait } from "../src/domain.ts" -import * as Store from "../src/store.ts" +import type { Wait } from "../src/shared/domain.ts" +import * as Store from "../src/shared/store.ts" const root = "/waits" /** An `ENOENT`-shaped error, matching what `node:fs/promises` throws for a * missing path, so the fake behaves like the real filesystem the plugin - * ships against (see `src/node.ts`). */ + * ships against (see `src/shared/node.ts`). */ const enoent = (path: string): NodeJS.ErrnoException => Object.assign(new Error(`ENOENT: no such file or directory, '${path}'`), { code: "ENOENT" }) @@ -16,7 +16,7 @@ const eexist = (path: string): NodeJS.ErrnoException => Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST" }) /** An in-memory `Store.FileSystem` fake over a `Map`, honouring - * the same contract as `src/node.ts`: `writeNew` is an exclusive create, + * the same contract as `src/shared/node.ts`: `writeNew` is an exclusive create, * `claim` is an idempotent unlink that never rejects for a missing file, and * `remove` rejects when the file is already gone (like a bare `rm`). */ const makeMemoryFileSystem = ( diff --git a/test/supervisor.test.ts b/test/supervisor.test.ts index 452c06c..effd327 100644 --- a/test/supervisor.test.ts +++ b/test/supervisor.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test" import { Session } from "@opencode/schema/session" import { Duration, Effect, Exit, Layer, Option, Queue, Scope } from "effect" import { TestClock } from "effect/testing" -import * as Delivery from "../src/delivery.ts" -import type { Wait } from "../src/domain.ts" -import * as Store from "../src/store.ts" -import * as Supervisor from "../src/supervisor.ts" +import * as Delivery from "../src/server/delivery.ts" +import * as Supervisor from "../src/server/supervisor.ts" +import type { Wait } from "../src/shared/domain.ts" +import * as Store from "../src/shared/store.ts" const root = "/waits" diff --git a/test/tools.test.ts b/test/tools.test.ts index f7adc28..b7fd44c 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "bun:test" import { Session } from "@opencode/schema/session" import { Tool } from "@opencode/schema/tool" import { Cause, Duration, Effect, Exit, Option } from "effect" -import type { Wait } from "../src/domain.ts" -import type { ScheduleInput, Interface as Scheduler } from "../src/supervisor.ts" -import * as Tools from "../src/tools.ts" +import type { ScheduleInput, Interface as Scheduler } from "../src/server/supervisor.ts" +import * as Tools from "../src/server/tools.ts" +import type { Wait } from "../src/shared/domain.ts" /** Builds a `Wait` with sensible defaults, overridable per test. */ const makeWait = (overrides: Partial = {}): Wait => ({ @@ -168,10 +168,10 @@ describe("Tools.all", () => { }) }) -describe("wait_schedule", () => { +describe("schedule_create", () => { test("rejects an empty prompt without calling scheduler.schedule", async () => { const { scheduler, calls } = stubScheduler() - const tool = findTool(Tools.all(scheduler), "wait_schedule") + const tool = findTool(Tools.all(scheduler), "schedule_create") const context = fakeContext() const error = await runExpectingToolError( @@ -187,7 +187,7 @@ describe("wait_schedule", () => { test("rejects a whitespace-only prompt without calling scheduler.schedule", async () => { const { scheduler, calls } = stubScheduler() - const tool = findTool(Tools.all(scheduler), "wait_schedule") + const tool = findTool(Tools.all(scheduler), "schedule_create") const context = fakeContext() const error = await runExpectingToolError( @@ -203,7 +203,7 @@ describe("wait_schedule", () => { test("rejects an unparseable duration, mentioning the bad input, without scheduling", async () => { const { scheduler, calls } = stubScheduler() - const tool = findTool(Tools.all(scheduler), "wait_schedule") + const tool = findTool(Tools.all(scheduler), "schedule_create") const context = fakeContext() const error = await runExpectingToolError( @@ -220,7 +220,7 @@ describe("wait_schedule", () => { test("passes the parsed duration and sessionID through, trims the prompt, and reports the wait id", async () => { const wait = makeWait({ id: "w42" }) const { scheduler, calls } = stubScheduler({ schedule: () => wait }) - const tool = findTool(Tools.all(scheduler), "wait_schedule") + const tool = findTool(Tools.all(scheduler), "schedule_create") const context = fakeContext({ sessionID: wait.sessionID }) const result = await Effect.runPromise( @@ -241,24 +241,24 @@ describe("wait_schedule", () => { }) }) -describe("wait_list", () => { +describe("schedule_list", () => { test("reports no pending waits when the scheduler returns none", async () => { const { scheduler } = stubScheduler({ list: () => [] }) - const tool = findTool(Tools.all(scheduler), "wait_list") + const tool = findTool(Tools.all(scheduler), "schedule_list") const context = fakeContext() const result = await Effect.runPromise( tool.execute({}, context) as Effect.Effect, ) - expect(contentText(result).toLowerCase()).toContain("no pending waits") + expect(contentText(result).toLowerCase()).toContain("no pending scheduled prompts") }) test("lists each pending wait's id and prompt", async () => { const first = makeWait({ id: "w1", prompt: "first prompt" }) const second = makeWait({ id: "w2", prompt: "second prompt" }) const { scheduler } = stubScheduler({ list: () => [first, second] }) - const tool = findTool(Tools.all(scheduler), "wait_list") + const tool = findTool(Tools.all(scheduler), "schedule_list") const context = fakeContext() const result = await Effect.runPromise( @@ -273,7 +273,7 @@ describe("wait_list", () => { }) }) -describe("wait_cancel", () => { +describe("schedule_cancel", () => { describe("missing id regression guard", () => { // `Schema.optional` renders as `anyOf: [string, null]`, so a provider may // legitimately send `{ id: null }` rather than omitting the field. The @@ -288,7 +288,7 @@ describe("wait_cancel", () => { ] as const) { test(`{ id } ${label} fails with a Tool.Error, not a defect`, async () => { const { scheduler, calls } = stubScheduler({ list: () => [pending] }) - const tool = findTool(Tools.all(scheduler), "wait_cancel") + const tool = findTool(Tools.all(scheduler), "schedule_cancel") const context = fakeContext() const error = await runExpectingToolError( @@ -307,7 +307,7 @@ describe("wait_cancel", () => { const first = makeWait({ id: "w1", prompt: "first" }) const second = makeWait({ id: "w2", prompt: "second" }) const { scheduler, calls } = stubScheduler({ cancelAll: () => [first, second] }) - const tool = findTool(Tools.all(scheduler), "wait_cancel") + const tool = findTool(Tools.all(scheduler), "schedule_cancel") const context = fakeContext() const result = await Effect.runPromise( @@ -323,14 +323,14 @@ describe("wait_cancel", () => { test("{ all: true } reports nothing to cancel when cancelAll returns none", async () => { const { scheduler } = stubScheduler({ cancelAll: () => [] }) - const tool = findTool(Tools.all(scheduler), "wait_cancel") + const tool = findTool(Tools.all(scheduler), "schedule_cancel") const context = fakeContext() const result = await Effect.runPromise( tool.execute({ all: true }, context) as Effect.Effect, ) - expect(contentText(result).toLowerCase()).toContain("no pending waits") + expect(contentText(result).toLowerCase()).toContain("no pending scheduled prompts") }) test("a known id calls cancel and reports success", async () => { @@ -338,7 +338,7 @@ describe("wait_cancel", () => { const { scheduler, calls } = stubScheduler({ cancel: (_sessionID, id) => (id === wait.id ? Option.some(wait) : Option.none()), }) - const tool = findTool(Tools.all(scheduler), "wait_cancel") + const tool = findTool(Tools.all(scheduler), "schedule_cancel") const context = fakeContext() const result = await Effect.runPromise( @@ -353,7 +353,7 @@ describe("wait_cancel", () => { test("an unknown id fails with a Tool.Error", async () => { const { scheduler, calls } = stubScheduler({ cancel: () => Option.none(), list: () => [] }) - const tool = findTool(Tools.all(scheduler), "wait_cancel") + const tool = findTool(Tools.all(scheduler), "schedule_cancel") const context = fakeContext() const error = await runExpectingToolError( @@ -364,3 +364,117 @@ describe("wait_cancel", () => { expect(error.message).toContain("w999") }) }) + +describe("tool naming", () => { + const pairs = [ + ["schedule_create", "wait_schedule"], + ["schedule_list", "wait_list"], + ["schedule_cancel", "wait_cancel"], + ] as const + + test("registers canonical tools and wait_* aliases, with no duplicate names", () => { + const { scheduler } = stubScheduler() + const names = Tools.all(scheduler).map((tool) => tool.name) + + expect(new Set(names).size).toBe(names.length) + expect(names).toEqual([ + "schedule_create", + "schedule_list", + "schedule_cancel", + "wait_schedule", + "wait_list", + "wait_cancel", + ]) + }) + + for (const [canonical, legacy] of pairs) { + test(`${legacy} keeps ${canonical}'s input schema and options and points at it`, () => { + const { scheduler } = stubScheduler() + const tools = Tools.all(scheduler) + const current = findTool(tools, canonical) + const alias = findTool(tools, legacy) + + expect(JSON.stringify(alias.input)).toBe(JSON.stringify(current.input)) + expect(alias.options).toEqual(current.options) + expect(alias.description).toContain(canonical) + expect(current.description).not.toContain("wait_") + }) + } + + test("wait_schedule schedules exactly like schedule_create", async () => { + const { scheduler, calls } = stubScheduler() + const tools = Tools.all(scheduler) + const context = fakeContext() + const results: Array = [] + + for (const name of ["schedule_create", "wait_schedule"]) { + const result = await Effect.runPromise( + findTool(tools, name).execute( + { duration: "1hour", prompt: " go " }, + context, + ) as Effect.Effect, + ) + results.push(contentText(result)) + } + + expect(calls.schedule).toHaveLength(2) + expect(calls.schedule[1]).toEqual(calls.schedule[0]) + expect(calls.schedule[0]?.prompt).toBe("go") + expect(results[1]).toBe(results[0]) + }) + + test("wait_list and wait_cancel act on the invoking session like their canonical tools", async () => { + const wait = makeWait({ id: "w3", prompt: "later" }) + const { scheduler, calls } = stubScheduler({ + list: () => [wait], + cancel: (_sessionID, id) => (id === wait.id ? Option.some(wait) : Option.none()), + cancelAll: () => [wait], + }) + const tools = Tools.all(scheduler) + const context = fakeContext() + const run = (name: string, input: unknown) => + Effect.runPromise( + findTool(tools, name).execute(input, context) as Effect.Effect, + ).then(contentText) + + expect(await run("wait_list", {})).toBe(await run("schedule_list", {})) + expect(await run("wait_cancel", { id: "w3" })).toBe(await run("schedule_cancel", { id: "w3" })) + expect(await run("wait_cancel", { all: true })).toBe( + await run("schedule_cancel", { all: true }), + ) + expect(calls.list).toEqual([context.sessionID, context.sessionID]) + expect(calls.cancel).toEqual([ + { sessionID: context.sessionID, id: "w3" }, + { sessionID: context.sessionID, id: "w3" }, + ]) + expect(calls.cancelAll).toEqual([context.sessionID, context.sessionID]) + }) +}) + +describe("schedule_create confirmation", () => { + const confirm = async (id: string) => { + const { scheduler } = stubScheduler({ schedule: () => makeWait({ id }) }) + const result = await Effect.runPromise( + findTool(Tools.all(scheduler), "schedule_create").execute( + { duration: "5m", prompt: "go" }, + fakeContext(), + ) as Effect.Effect, + ) + return contentText(result) + } + + test("points at /schedule-cancel and says a persisted schedule survives a restart", async () => { + const content = await confirm("w5") + + expect(content).toContain("/schedule-cancel w5") + expect(content).toContain("survives an OpenCode restart") + expect(content).not.toContain("lost") + }) + + test("warns only an unpersisted in-memory fallback is lost on restart", async () => { + const content = await confirm("mem-123") + + expect(content).toContain("/schedule-cancel mem-123") + expect(content).toContain("lost if OpenCode restarts") + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 06c653e..4f44441 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ import { gui } from "@opencode/plugin/gui/vite" import { defineConfig } from "vite" -export default defineConfig({ plugins: [gui()] }) +export default defineConfig({ plugins: [gui({ entry: "src/gui/index.tsx" })] })