Directory structure: └── packages/ ├── app/ │ ├── index.html │ ├── package.json │ ├── progress.json │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── pr/ │ │ ├── openwork-orchestrator.md │ │ ├── openwork-server.md │ │ └── plugin-endpoints.md │ ├── scripts/ │ │ ├── _util.mjs │ │ ├── bump-version.mjs │ │ ├── e2e.mjs │ │ ├── events.mjs │ │ ├── fs-engine.mjs │ │ ├── health.mjs │ │ ├── permissions.mjs │ │ ├── select-session-debug.mjs │ │ ├── session-switch.mjs │ │ ├── sessions-parallel.mjs │ │ ├── sessions.mjs │ │ └── todos.mjs │ └── src/ │ ├── index.tsx │ ├── app/ │ │ ├── command-registry.ts │ │ ├── command-state.ts │ │ ├── constants.ts │ │ ├── entry.tsx │ │ ├── index.css │ │ ├── mcp.ts │ │ ├── system-state.ts │ │ ├── theme.ts │ │ ├── types.ts │ │ ├── components/ │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── command-modal.tsx │ │ │ ├── command-palette-modal.tsx │ │ │ ├── command-run-modal.tsx │ │ │ ├── confirm-modal.tsx │ │ │ ├── create-remote-workspace-modal.tsx │ │ │ ├── create-workspace-modal.tsx │ │ │ ├── flyout-item.tsx │ │ │ ├── language-picker-modal.tsx │ │ │ ├── mcp-auth-modal.tsx │ │ │ ├── model-picker-modal.tsx │ │ │ ├── onboarding-workspace-selector.tsx │ │ │ ├── openwork-logo.tsx │ │ │ ├── part-view.tsx │ │ │ ├── provider-auth-modal.tsx │ │ │ ├── reload-workspace-toast.tsx │ │ │ ├── rename-session-modal.tsx │ │ │ ├── reset-modal.tsx │ │ │ ├── settings-keybinds.tsx │ │ │ ├── status-bar.tsx │ │ │ ├── text-input.tsx │ │ │ ├── thinking-block.tsx │ │ │ ├── workspace-chip.tsx │ │ │ ├── workspace-picker.tsx │ │ │ ├── workspace-switch-overlay.tsx │ │ │ └── session/ │ │ │ ├── composer.tsx │ │ │ ├── context-panel.tsx │ │ │ ├── message-list.tsx │ │ │ ├── minimap.tsx │ │ │ └── sidebar.tsx │ │ ├── context/ │ │ │ ├── extensions.ts │ │ │ ├── global-sdk.tsx │ │ │ ├── global-sync.tsx │ │ │ ├── local.tsx │ │ │ ├── platform.tsx │ │ │ ├── server.tsx │ │ │ ├── session.ts │ │ │ ├── sync.tsx │ │ │ ├── updater.ts │ │ │ └── workspace.ts │ │ ├── data/ │ │ │ └── skill-creator.md │ │ ├── lib/ │ │ │ ├── opencode.ts │ │ │ ├── openwork-server.ts │ │ │ └── tauri.ts │ │ ├── pages/ │ │ │ ├── commands.tsx │ │ │ ├── dashboard.tsx │ │ │ ├── mcp.tsx │ │ │ ├── onboarding.tsx │ │ │ ├── plugins.tsx │ │ │ ├── proto-workspaces.tsx │ │ │ ├── session.tsx │ │ │ └── skills.tsx │ │ ├── state/ │ │ │ ├── extensions.ts │ │ │ ├── sessions.ts │ │ │ └── system.ts │ │ └── utils/ │ │ ├── commands.ts │ │ ├── index.ts │ │ ├── keybinds.ts │ │ ├── persist.ts │ │ ├── plugins.ts │ │ └── providers.ts │ ├── i18n/ │ │ ├── index.ts │ │ └── locales/ │ │ ├── en.ts │ │ ├── index.ts │ │ └── zh.ts │ └── styles/ │ └── tailwind-colors.ts ├── desktop/ │ ├── package.json │ ├── scripts/ │ │ └── prepare-sidecar.mjs │ └── src-tauri/ │ ├── build.rs │ ├── Cargo.toml │ ├── entitlements.plist │ ├── tauri.conf.json │ ├── capabilities/ │ │ └── default.json │ ├── gen/ │ │ └── schemas/ │ │ └── capabilities.json │ ├── icons/ │ │ └── android/ │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_launcher.xml │ │ └── values/ │ │ └── ic_launcher_background.xml │ └── src/ │ ├── config.rs │ ├── fs.rs │ ├── lib.rs │ ├── main.rs │ ├── opkg.rs │ ├── paths.rs │ ├── types.rs │ ├── updater.rs │ ├── utils.rs │ ├── commands/ │ │ ├── command_files.rs │ │ ├── config.rs │ │ ├── engine.rs │ │ ├── misc.rs │ │ ├── mod.rs │ │ ├── openwork_server.rs │ │ ├── opkg.rs │ │ ├── owpenbot.rs │ │ ├── skills.rs │ │ ├── updater.rs │ │ └── workspace.rs │ ├── engine/ │ │ ├── doctor.rs │ │ ├── manager.rs │ │ ├── mod.rs │ │ ├── paths.rs │ │ └── spawn.rs │ ├── openwork_server/ │ │ ├── manager.rs │ │ ├── mod.rs │ │ └── spawn.rs │ ├── owpenbot/ │ │ ├── manager.rs │ │ ├── mod.rs │ │ └── spawn.rs │ ├── platform/ │ │ ├── mod.rs │ │ ├── unix.rs │ │ └── windows.rs │ └── workspace/ │ ├── commands.rs │ ├── files.rs │ ├── mod.rs │ ├── state.rs │ └── watch.rs ├── owpenbot/ │ ├── README.md │ ├── install.sh │ ├── package.json │ ├── tsconfig.json │ ├── .env.example │ ├── script/ │ │ └── build.ts │ ├── scripts/ │ │ ├── setup.mjs │ │ ├── smoke.mjs │ │ ├── test-cli.mjs │ │ └── test-npx.mjs │ ├── src/ │ │ ├── bridge.ts │ │ ├── cli.ts │ │ ├── config.ts │ │ ├── db.ts │ │ ├── events.ts │ │ ├── health.ts │ │ ├── logger.ts │ │ ├── opencode.ts │ │ ├── telegram.ts │ │ ├── text.ts │ │ ├── whatsapp-session.ts │ │ └── whatsapp.ts │ └── test/ │ └── db.test.js └── server/ ├── README.md ├── package.json ├── tsconfig.json ├── script/ │ └── build.ts └── src/ ├── approvals.ts ├── audit.ts ├── bun.d.ts ├── cli.ts ├── commands.ts ├── config.ts ├── errors.ts ├── frontmatter.ts ├── jsonc.ts ├── mcp.ts ├── paths.ts ├── plugins.ts ├── server.ts ├── skills.ts ├── types.ts ├── utils.ts ├── validators.ts ├── workspace-files.ts └── workspaces.ts ================================================ FILE: packages/app/index.html ================================================ OpenWork
================================================ FILE: packages/app/package.json ================================================ { "name": "@different-ai/openwork-ui", "private": true, "version": "0.7.1", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "dev:web": "vite", "build:web": "vite build", "preview": "vite preview", "typecheck": "tsc -p tsconfig.json --noEmit", "test:health": "node scripts/health.mjs", "test:sessions": "node scripts/sessions.mjs", "test:refactor": "pnpm typecheck && pnpm test:health && pnpm test:sessions", "test:events": "node scripts/events.mjs", "test:todos": "node scripts/todos.mjs", "test:permissions": "node scripts/permissions.mjs", "test:session-switch": "node scripts/session-switch.mjs", "test:fs-engine": "node scripts/fs-engine.mjs", "test:e2e": "node scripts/e2e.mjs && node scripts/session-switch.mjs && node scripts/fs-engine.mjs", "bump:patch": "node scripts/bump-version.mjs patch", "bump:minor": "node scripts/bump-version.mjs minor", "bump:major": "node scripts/bump-version.mjs major", "bump:set": "node scripts/bump-version.mjs --set" }, "dependencies": { "@opencode-ai/sdk": "^1.1.31", "@radix-ui/colors": "^3.0.0", "@solid-primitives/event-bus": "^1.1.2", "@solid-primitives/storage": "^4.3.3", "@solidjs/router": "^0.15.4", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "~2.3.1", "@tauri-apps/plugin-updater": "~2.9.0", "jsonc-parser": "^3.2.1", "lucide-solid": "^0.562.0", "marked": "^17.0.1", "solid-js": "^1.9.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", "tailwindcss": "^4.1.18", "typescript": "^5.6.3", "vite": "^6.0.1", "vite-plugin-solid": "^2.11.0" }, "packageManager": "pnpm@10.27.0" } ================================================ FILE: packages/app/progress.json ================================================ { "project": "openwork", "target": "v0.3", "milestones": { "v0.1": { "description": "Engine + client", "items": [ { "id": "v0.1-1", "title": "Tauri app shell", "status": "completed" }, { "id": "v0.1-2", "title": "Start/stop OpenCode server (host mode)", "status": "completed" }, { "id": "v0.1-3", "title": "Connect client and show health", "status": "completed" }, { "id": "v0.1-4", "title": "List sessions", "status": "completed" } ] }, "v0.2": { "description": "Full run loop", "items": [ { "id": "v0.2-1", "title": "Create session", "status": "completed" }, { "id": "v0.2-2", "title": "Send prompt", "status": "completed" }, { "id": "v0.2-3", "title": "Subscribe to SSE events", "status": "completed" }, { "id": "v0.2-4", "title": "Render step/tool timeline", "status": "completed" }, { "id": "v0.2-5", "title": "Surface permission requests + respond", "status": "completed" } ] }, "v0.3": { "description": "Premium UX", "items": [ { "id": "v0.3-1", "title": "Design-driven UI (design.ts)", "status": "completed" }, { "id": "v0.3-2", "title": "Mobile navigation + responsive layouts", "status": "completed" }, { "id": "v0.3-3", "title": "Templates (create/save/run)", "status": "completed" }, { "id": "v0.3-4", "title": "Skills manager (list/install/import)", "status": "completed" }, { "id": "v0.3-5", "title": "Folder picker (native dialog)", "status": "completed" }, { "id": "v0.3-6", "title": "No reasoning/tool metadata leaks", "status": "completed" } ] } }, "notes": [ "Frontend uses @opencode-ai/sdk/v2/client to avoid bundling Node-only server code.", "Engine spawns `opencode serve` from Rust with a per-run working directory.", "`cargo check` runs successfully; a placeholder icon exists at `packages/desktop/src-tauri/icons/icon.png`.", "OpenCode mirror cloned at `vendor/opencode` (gitignored).", "UI follows `design.ts` (ported to Solid + Tailwind) and is wired to real OpenCode v2 sessions/messages/todos/permissions.", "Folder picking uses Tauri dialog plugin (no manual path required in Host mode).", "Templates UI exists (create/save/run) backed by localStorage.", "Skills UI exists: list installed `.opencode/skill` and install/import via OpenPackage + local folder import (Host mode).", "Redacts sensitive metadata keys (e.g. reasoningEncryptedContent) from UI to prevent leaks.", "`pnpm typecheck` and `pnpm build:ui` succeed.", "Permissions entrypoint exists, but permission prompts may not appear without agent-driven tool calls." ], "lastUpdated": "2026-01-14" } ================================================ FILE: packages/app/tailwind.config.ts ================================================ import { radixColors, tailwindSafelist } from './src/styles/tailwind-colors'; export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], darkMode: 'class', safelist: [ tailwindSafelist ], theme: { // OVERRIDE the base theme completely instead of extending it colors: { ...radixColors, } } }; ================================================ FILE: packages/app/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "preserve", "jsxImportSource": "solid-js", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "strict": true, "skipLibCheck": true, "types": ["vite/client"] }, "include": ["src", "vite.config.ts"] } ================================================ FILE: packages/app/vite.config.ts ================================================ import { defineConfig } from "vite"; import tailwindcss from "@tailwindcss/vite"; import solid from "vite-plugin-solid"; const portValue = Number.parseInt(process.env.PORT ?? "", 10); const devPort = Number.isFinite(portValue) && portValue > 0 ? portValue : 5173; export default defineConfig({ plugins: [tailwindcss(), solid()], server: { port: devPort, strictPort: true, }, build: { target: "esnext", }, }); ================================================ FILE: packages/app/pr/openwork-orchestrator.md ================================================ # PRD: OpenWork Orchestrator (Host-First + Fallback Remote) ## Summary Reframe OpenWork as the lifecycle supervisor for OpenCode. Clients connect to an OpenWork host, which returns the OpenCode connection details for the active workspace. If the host URL is not an OpenWork server, fall back to the existing direct-OpenCode flow so remote workspaces still work. **Simplest design decision:** the OpenWork host exposes **only the active workspace**. No multi-workspace share list. When the host switches workspaces, clients follow it. ## Goals - Host mode: OpenWork starts and supervises OpenCode and exposes a pairing endpoint for clients. - Client mode: connect to OpenWork host URL + token; host provides OpenCode base URL + directory. - Fallback: if a user enters a URL that is not an OpenWork host, connect directly to OpenCode as today. - UI: the connection flow and workspace surfaces reflect OpenWork host vs direct OpenCode. ## Non-goals - Multiple shared workspaces or “pinned” workspace lists. - Peer discovery or QR pairing (future). - New auth systems beyond bearer token. - New OpenCode APIs. ## User flows ### Host mode 1) User picks a local workspace. 2) OpenWork starts OpenCode (`opencode serve`). 3) OpenWork starts OpenWork server and registers the active workspace. 4) Settings shows pairing URL + client token. ### Client mode (OpenWork host) 1) User enters **OpenWork Host URL** + token. 2) OpenWork client calls host `/health` and `/workspaces`. 3) Host returns active workspace + OpenCode base URL + directory. 4) Client connects to OpenCode using existing SDK flow. 5) Skills/plugins/config actions route to OpenWork host (preferred). If unavailable, fall back to OpenCode or show read-only. ### Client mode (fallback to OpenCode) 1) User enters a URL that is not an OpenWork host. 2) Client attempts OpenWork `/health` and fails with non-OpenWork response. 3) Client treats the URL as OpenCode base URL (existing flow). ## API contract (OpenWork host) **Base URL:** `http(s)://:` **Auth:** `Authorization: Bearer ` ### `GET /health` Returns `{ healthy: true, version: string }`. If missing or 404, treat as non-OpenWork host and fallback to OpenCode. ### `GET /workspaces` Returns only the active workspace: ``` { active: { id: "ws-123", name: "My Workspace", opencode: { baseUrl: "http://127.0.0.1:4096", directory: "/path/to/workspace" } } } ``` ### `GET /workspaces/active` Alias for the active workspace payload (optional). ### `GET /capabilities` Returns `{ skills: { read, write }, plugins: { read, write }, mcp: { read, write } }`. ## Data model changes **WorkspaceInfo** (Tauri + UI) must differentiate remote OpenWork vs direct OpenCode: - `remoteType: "openwork" | "opencode"` - `openworkHostUrl?: string` - `openworkWorkspaceId?: string` - `opencodeBaseUrl?: string` (existing `baseUrl` becomes this) - `opencodeDirectory?: string` (existing `directory` becomes this) **Workspace ID** - For OpenWork remote: stable ID should include `openworkHostUrl + openworkWorkspaceId`. - For OpenCode remote: keep current `stable_workspace_id_for_remote(baseUrl, directory)`. ## UI rewires (specific components) ### Onboarding client step File: `packages/app/src/app/pages/onboarding.tsx` - Replace “Remote base URL” with **OpenWork Host URL**. - Add **Access token** input. - Add “Advanced: Connect directly to OpenCode” toggle that reveals the current baseUrl + directory inputs. - Submit button calls `onConnectClient()` which attempts OpenWork first, then fallback. ### Create Remote Workspace modal File: `packages/app/src/app/components/create-remote-workspace-modal.tsx` - Primary fields: **OpenWork Host URL** + **Access token**. - Advanced toggle: **Direct OpenCode base URL** + directory. - Store `remoteType` in workspace state based on which input path is used. ### Workspace picker + switch overlay Files: - `packages/app/src/app/components/workspace-picker.tsx` - `packages/app/src/app/components/workspace-switch-overlay.tsx` Changes: - Show badge: **OpenWork** vs **OpenCode** for remote workspaces. - Primary line: OpenWork host URL (if OpenWork remote) else OpenCode baseUrl. - Secondary line: workspace name from host (OpenWork) or directory (OpenCode). ### Settings connection card File: `packages/app/src/app/pages/settings.tsx` - Show **OpenWork host status** when in client mode: URL, connection state, token status. - Host mode: show **pairing URL + client token** from OpenWork server. - Keep OpenCode engine status visible for host mode only. ## State + logic rewires (exact mapping) ### Workspace connection flow File: `packages/app/src/app/context/workspace.ts` - Split current `connectToServer()` into: - `connectToOpenworkHost(hostUrl, token)` - `connectToOpencode(baseUrl, directory)` (existing logic) - Update `createRemoteWorkspaceFlow()` to: 1) Try OpenWork host handshake. 2) If handshake fails (non-OpenWork), fallback to OpenCode base URL path. - Update `activateWorkspace()` to branch based on `remoteType`. ### Client + header status File: `packages/app/src/app/app.tsx` - Track OpenWork host connection state alongside OpenCode client state. - Header status should prefer OpenWork host state in client mode (e.g., “Connected · OpenWork”). ### Extensions (skills/plugins/mcp) File: `packages/app/src/app/context/extensions.ts` - If remoteType is `openwork` and host capabilities allow, use OpenWork server endpoints for: - skills list/install/remove - plugin list/add/remove (project scope only) - If remoteType is `opencode`, keep current OpenCode-only behavior (read-only or host-only). ## Host lifecycle changes **OpenWork host** must manage OpenWork server alongside OpenCode: - Start OpenWork server after OpenCode engine starts. - Update OpenWork server when active workspace changes. - Expose pairing URL + token to UI. Files (desktop): - `packages/desktop/src-tauri/src/commands/engine.rs` - `packages/desktop/src-tauri/src/lib.rs` - `packages/desktop/src-tauri/src/types.rs` - `packages/desktop/src-tauri/src/commands/workspace.rs` ## Fallback behavior (explicit) - If `GET /health` fails (404, network error, non-JSON), treat the input as a direct OpenCode base URL. - The UI should show a small inline hint: “Connected via OpenCode (not OpenWork).” ## Migration - Existing remote workspaces stored as OpenCode remotes remain valid. - New OpenWork remotes store `remoteType = openwork` with host URL + workspace ID. - No changes to local workspaces. ## Risks - Confusing connection state (OpenWork vs OpenCode). Mitigate with badges + status text. - Host switching workspace unexpectedly disconnects client. Mitigate with a short toast + auto-reconnect. - Non-OpenWork URLs falsely detected. Mitigate with clear fallback flow. ## Open questions - Do we need a QR pairing artifact now, or later? - Should host expose a “Read-only mode” toggle for shared clients? - Should OpenWork server enforce token rotation or persistence? ## Acceptance criteria - Client can connect to OpenWork host and OpenWork supplies OpenCode base URL + directory. - Entering a non-OpenWork URL still connects via OpenCode with no regression. - UI clearly distinguishes OpenWork vs OpenCode remote connections. ================================================ FILE: packages/app/pr/openwork-server.md ================================================ # OpenWork Server Bridge missing capabilities between OpenWork and OpenCode --- ## Summarize Introduce an OpenWork server layer that fills gaps in OpenCode APIs, enabling remote clients to manage workspace config, skills, plugins, and MCPs without direct filesystem access. --- ## Define problem Remote clients cannot read or write workspace config because critical state lives in the filesystem. OpenWork needs a safe, minimal surface to access and mutate config, skills, plugins, and MCPs when connected to a host. --- ## Set goals - Bridge OpenWork needs that OpenCode does not expose today - Enable remote clients to view and update workspace config safely - Keep the surface minimal, auditable, and aligned to OpenCode primitives --- ## Mark non-goals - Replacing OpenCode's server or duplicating its APIs - Arbitrary filesystem access outside approved workspace roots - Hosting multi-tenant or cloud-managed instances --- ## Describe personas - Remote client user: needs visibility into installed skills/plugins while connected - Host operator: wants safe, explicit control over config changes --- ## List requirements - Functional: expose workspace config read/write APIs for `.opencode` and `opencode.json` - Functional: list installed skills, plugins, MCPs for a workspace without direct FS access - Functional: allow saving new skills, plugins, and MCP entries from a remote client - Functional: host mode auto-starts the OpenWork server alongside the OpenCode engine - UX: surface pairing URL + tokens in Settings for host mode - UX: show remote-config origin, last updated time, and change attribution --- ## Define API (initial) All endpoints are scoped to an approved workspace root and require host approval for writes. When OpenCode already exposes a stable API (agents, skills, MCP status), prefer OpenCode directly and avoid duplicating it here. This server only covers filesystem-backed gaps. ### API conventions - Base URL: provided by the host during pairing (e.g., `http://host:PORT/openwork`) - Content-Type: `application/json` - Auth: bearer token issued during pairing (`Authorization: Bearer `) - Errors: JSON body with `{ code, message, details? }` - All writes require explicit host approval and return `403` when denied ### Workspaces (discovery) - `GET /workspaces` -> list known workspaces on the host - Response fields align with `WorkspaceInfo` (id, name, path, workspaceType, baseUrl?, directory?) - Used by remote clients to select a workspace without filesystem access ### Health - `GET /health` -> { ok, version, uptimeMs } ### Capabilities - `GET /capabilities` -> { skills: { read, write, source }, plugins: { read, write }, mcp: { read, write }, commands: { read, write }, config: { read, write } } - `source` indicates whether OpenCode or OpenWork server is the authoritative API ### Workspace config - `GET /workspace/:id/config` -> returns parsed `opencode.json` + `.opencode/openwork.json` - `PATCH /workspace/:id/config` -> merges and writes config (write approval required) - Request body: `{ opencode?: object, openwork?: object }` - Merge strategy: shallow merge at top-level keys; arrays replaced (aligns with OpenCode config behavior) - Response: `{ opencode, openwork, updatedAt }` - Only project config is writable by default; global config requires explicit host-only scope ### Skills - Prefer OpenCode skills API when available; OpenWork server only fills local FS gaps. - `GET /workspace/:id/skills` -> list skill metadata from `.opencode/skills` (fallback only) - `POST /workspace/:id/skills` -> add/update skill file(s) (write approval required, fallback only) - Request body: `{ name, content, description? }` - Writes to `.opencode/skills//SKILL.md` - Response: `{ name, path, description, scope }` ### Plugins - `GET /workspace/:id/plugins` -> list configured plugins from `opencode.json` - `POST /workspace/:id/plugins` -> add plugin entry to config (write approval required) - `DELETE /workspace/:id/plugins/:name` -> remove plugin entry (write approval required) - Request body (POST): `{ spec }` where `spec` is a plugin string (npm or file URL) - Response: `{ items: [{ spec, source, scope, path? }], loadOrder: string[] }` - Server de-dupes by normalized name (strip version where possible) ### MCPs - Prefer OpenCode MCP APIs for status/runtime; OpenWork server only reads/writes config. - `GET /workspace/:id/mcp` -> list configured MCP servers - `POST /workspace/:id/mcp` -> add MCP config entry (write approval required) - `DELETE /workspace/:id/mcp/:name` -> remove MCP config entry (write approval required) - Request body (POST): `{ name, config }` where `config` matches `McpServerConfig` - Response: `{ items: [{ name, config, source }] }` ### Agents - Prefer OpenCode agents API; no OpenWork server endpoints needed unless OpenCode lacks coverage for remote clients. ### Commands - `GET /workspace/:id/commands` -> list commands from `.opencode/commands` (fallback only) - Optional query: `scope=workspace|global` (global requires host-only approval) - `POST /workspace/:id/commands` -> create/update command (write approval required) - `DELETE /workspace/:id/commands/:name` -> delete command (write approval required) - Request body (POST): `{ name, description?, template, agent?, model?, subtask? }` - Server writes `.opencode/commands/.md` with YAML frontmatter --- ## OpenCode behavior reference (from docs) This section captures the exact OpenCode semantics the OpenWork server must respect. ### Plugins - Config list: `opencode.json` -> `plugin` field. Can be string or string[]. - Local plugin folders: - Project: `.opencode/plugins/` - Global: `~/.config/opencode/plugins/` - Load order (all hooks run in sequence): 1) Global config `~/.config/opencode/opencode.json` 2) Project config `opencode.json` 3) Global plugin dir `~/.config/opencode/plugins/` 4) Project plugin dir `.opencode/plugins/` - NPM plugins are installed automatically using Bun at startup. - Cached node_modules live in `~/.cache/opencode/node_modules/`. - Local plugins can use dependencies listed in `.opencode/package.json`. - Duplicate npm packages with the same name+version load once; local and npm with similar names both load. ### Skills - Skill discovery paths (project): - `.opencode/skills//SKILL.md` - `.claude/skills//SKILL.md` - Skill discovery paths (global): - `~/.config/opencode/skills//SKILL.md` - `~/.claude/skills//SKILL.md` - Discovery walks up from current working directory to git worktree root. - `SKILL.md` must include YAML frontmatter with `name` and `description`. - Name constraints: - 1-64 chars - lowercase alphanumeric with single hyphen separators - must match directory name - regex: `^[a-z0-9]+(-[a-z0-9]+)*$` - Description length: 1-1024 chars. - Permissions: `opencode.json` `permission.skill` supports `allow`, `deny`, `ask` patterns. ### MCP servers - Config lives in `opencode.json` under `mcp` object. - Local MCP fields: `type: "local"`, `command[]`, optional `environment`, `enabled`, `timeout`. - Remote MCP fields: `type: "remote"`, `url`, optional `headers`, `oauth`, `enabled`, `timeout`. - `oauth: false` disables automatic OAuth detection. - Remote defaults can be provided via `.well-known/opencode`; local config overrides. - MCP tools can be disabled globally in `tools` via glob patterns. - Per-agent tool enabling overrides global settings. ### OpenCode server APIs - OpenCode already exposes: `/config`, `/mcp` (runtime), `/agent`, `/command` (list), `/session`. - OpenWork server should prefer OpenCode APIs for runtime status and only handle FS-backed config gaps. - When reading config, prefer OpenCode `/config` to capture precedence (remote defaults + global + project). - When writing, only modify project `opencode.json` to avoid clobbering upstream defaults. - `/command` is list-only; OpenWork server adds create/delete via filesystem for remote clients. ### Config precedence (reference) - Remote defaults from `.well-known/opencode` - Global config `~/.config/opencode/opencode.json` - Project config `opencode.json` - `.opencode/` directories and inline env overrides - OpenWork server should preserve this ordering when presenting config sources --- ## OpenCode server alignment (from docs) - OpenCode runs an HTTP server via `opencode serve` (default `127.0.0.1:4096`). - `--cors` can be used to allow browser origins; OpenWork should align with that for web clients. - Basic auth can be enabled via `OPENCODE_SERVER_PASSWORD` (username defaults to `opencode`). - The OpenWork server should not bypass OpenCode auth; if OpenCode is password-protected, the host UI must collect credentials and pass them to the client. - OpenCode publishes its OpenAPI spec at `/doc`; the OpenWork server should track upstream changes and avoid duplicating stable APIs. --- ## Host auto-start + pairing UX - When OpenWork runs in Host mode, it starts the OpenWork server automatically after the OpenCode engine comes online. - The host UI exposes a pairing card in Settings with: - OpenWork Server URL (prefers `.local` hostname, falls back to LAN IP) - Client token (for remote devices) - Host token (for approvals) - Tokens are generated per run unless supplied by host config. - Pairing info should be copyable (tap-to-copy) and masked by default. --- ## Endpoint examples (requests and responses) ### List workspaces Request: ``` GET /workspaces Authorization: Bearer ``` Response: ```json { "items": [ { "id": "ws_1", "name": "Finance", "path": "/Users/susan/Finance", "workspaceType": "local" }, { "id": "ws_2", "name": "Remote Ops", "path": "/Users/bob/Shared", "workspaceType": "remote", "baseUrl": "http://10.0.0.8:4096" } ] } ``` ### Get config Request: ``` GET /workspace/ws_1/config ``` Response: ```json { "opencode": { "plugin": ["opencode-github"], "mcp": { "chrome": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } } }, "openwork": { "version": 1, "authorizedRoots": ["/Users/susan/Finance"] } } ``` ### Patch config Request: ```json PATCH /workspace/ws_1/config { "opencode": { "plugin": ["opencode-github", "opencode-notion"] } } ``` Response: ```json { "updatedAt": 1730000000000 } ``` ### Add plugin Request: ```json POST /workspace/ws_1/plugins { "spec": "opencode-notion" } ``` Response: ```json { "items": [ { "spec": "opencode-github", "source": "config", "scope": "project" }, { "spec": "opencode-notion", "source": "config", "scope": "project" } ], "loadOrder": ["config.global", "config.project", "dir.global", "dir.project"] } ``` ### Add skill Request: ```json POST /workspace/ws_1/skills { "name": "expense-audit", "content": "# Expense Audit\n..." } ``` Response: ```json { "name": "expense-audit", "path": ".opencode/skills/expense-audit", "description": "Audit expenses...", "scope": "project" } ``` ### Add MCP server Request: ```json POST /workspace/ws_1/mcp { "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } } ``` Response: ```json { "items": [{ "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] }, "source": "config.project" }] } ``` ### Add command Request: ```json POST /workspace/ws_1/commands { "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default" } ``` Response: ```json { "items": [{ "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default", "scope": "workspace" }] } ``` --- ## Outline integration - Host side: OpenWork server exposes a narrow API layer on top of OpenCode - Implementation: Bun-based server initially, shipped as a sidecar inside the OpenWork desktop app - Client side: OpenWork UI uses this layer when remote, falls back to FS when local - Storage: persists changes to `.opencode` and `opencode.json` within workspace root --- ## Server runtime and sidecar lifecycle - The desktop app launches the OpenWork server as a sidecar process. - The server binds to localhost on an ephemeral port and reports its URL back to the host UI. - The host UI includes the OpenWork server URL in the pairing payload for remote clients. - On crash, the host restarts the server and re-issues capabilities. - The server must never expose filesystem paths outside approved workspace roots. --- ## Workspace identity and scoping - Workspaces are referenced by `workspace.id` (stable hash of the workspace path). - Remote clients fetch the list via `GET /workspaces` and select a target id. - Each request includes `workspaceId` in the path; server resolves to a validated root. - If the workspace is missing or not authorized, return `404` or `403`. --- ## Authentication and pairing - The host generates a short-lived pairing token and includes the OpenWork server base URL in the pairing payload. - Remote clients store the token in memory (not on disk) and send it as `Authorization: Bearer `. - Tokens are scoped to the host and expire on disconnect or after a short TTL (e.g., 24h). - The OpenWork server rejects requests without a valid token (`401`). - Future: rotate tokens on reconnect and support revocation from host settings. --- ## Capability schema (example) ```json { "skills": { "read": true, "write": true, "source": "openwork" }, "plugins": { "read": true, "write": true }, "mcp": { "read": true, "write": true }, "commands": { "read": true, "write": true }, "config": { "read": true, "write": true } } ``` --- ## Audit log format (example) ```json { "id": "audit_123", "workspaceId": "ws_abc", "actor": { "type": "remote", "clientId": "client_1" }, "action": "plugins.add", "target": "opencode.json", "summary": "Added opencode-github", "timestamp": 1730000000000 } ``` --- ## Web app integration - Remote clients connect to both OpenCode (engine) and OpenWork server (config layer) - Capability check gates UI actions: if OpenWork server is missing, config actions are read-only - Writes require host approval and are surfaced in the audit log - Future: this evolves into a sync layer across clients (out of scope here) --- ## Client caching and consistency - Cache the last successful config snapshot per workspace in local state. - On write success, refresh via `GET /workspace/:id/config` before updating UI. - Use optimistic UI only for read-only lists; for writes, wait for approval + server response. - If OpenWork server is unreachable, show read-only data and a reconnect CTA. --- ## Sequence flows (examples) ### Add plugin (remote) 1) User clicks “Add plugin” in Plugins page. 2) `context/extensions.ts` calls `POST /workspace/:id/plugins` with `{ spec }`. 3) Server requests host approval; host approves. 4) Server writes `opencode.json`, returns updated list. 5) Client refreshes plugins list and shows success toast. ### Add skill (remote) 1) User uploads a skill in Skills page. 2) Client sends `POST /workspace/:id/skills` with `{ name, content }`. 3) Server writes `.opencode/skills//SKILL.md` after approval. 4) Client refreshes skills list. ### Add MCP (remote) 1) User fills MCP config form. 2) Client sends `POST /workspace/:id/mcp` with `{ name, config }`. 3) Server merges into `opencode.json` and returns updated list. 4) UI shows “Reload engine” banner if required. --- ## OpenWork UI wiring (specific) These are the concrete integration points inside `packages/app`. ### Data layer - `src/app/lib/opencode.ts`: keep as-is for OpenCode engine calls - `src/app/lib/tauri.ts`: host-only FS actions (local) stay here - **New** `src/app/lib/openwork-server.ts`: remote config API client (HTTP) + capability check Example client surface (TypeScript): ```ts type Capabilities = { skills: { read: boolean; write: boolean; source: "opencode" | "openwork" }; plugins: { read: boolean; write: boolean }; mcp: { read: boolean; write: boolean }; commands: { read: boolean; write: boolean }; config: { read: boolean; write: boolean }; }; export const openworkServer = { health(): Promise<{ ok: boolean; version: string; uptimeMs: number }>; capabilities(): Promise; listWorkspaces(): Promise; getConfig(id: string): Promise<{ opencode: object; openwork: object }>; patchConfig(id: string, body: { opencode?: object; openwork?: object }): Promise; listPlugins(id: string): Promise; addPlugin(id: string, spec: string): Promise; removePlugin(id: string, name: string): Promise; listSkills(id: string): Promise; upsertSkill(id: string, payload: { name: string; content: string }): Promise; listMcp(id: string): Promise; addMcp(id: string, payload: { name: string; config: McpServerConfig }): Promise; removeMcp(id: string, name: string): Promise; listCommands(id: string): Promise; upsertCommand(id: string, payload: WorkspaceCommand): Promise; deleteCommand(id: string, name: string): Promise; }; ``` ### State stores - `src/app/context/workspace.ts`: route remote config reads/writes to OpenWork server when workspaceType is `remote` - `src/app/context/extensions.ts`: use OpenWork server to list/add skills/plugins/MCPs in remote mode - `src/app/context/session.ts`: no changes; stays on OpenCode engine ### UI surfaces - `src/app/pages/dashboard.tsx`: display workspace config status + enable “Share config” only when supported - `src/app/pages/skills.tsx`: list and import skills via OpenWork server in remote mode - `src/app/pages/plugins.tsx`: list/add/remove plugins via OpenWork server in remote mode - `src/app/pages/mcp.tsx`: list/connect MCPs via OpenWork server in remote mode - `src/app/pages/commands.tsx`: list/add/remove commands via OpenWork server in remote mode - `src/app/pages/session.tsx`: surface agent list/selection via OpenWork server when remote if OpenCode lacks agent APIs - `src/app/components/workspace-chip.tsx` + `workspace-picker.tsx`: show capability badges (read-only if server missing) ### Capability checks - On connect, call `GET /health` on OpenWork server - Store a `serverCapabilities` flag in app state and guard remote config actions --- ## Detailed wiring notes This section explains exactly how requests flow and where the UI switches between local FS and the OpenWork server. ### Connection flow (remote) 1) User connects to a host (OpenCode engine). The client already has a base URL for OpenCode. 2) The client derives or receives the OpenWork server base URL from the host pairing payload. 3) The client calls `GET /health` and `GET /capabilities` on the OpenWork server. 4) UI stores `openworkServerStatus` (ok/error) and `openworkServerCapabilities` in app state. 5) All config-mutating UI surfaces check capabilities before enabling write actions. ### Local vs remote switching - **Local workspaces**: use Tauri FS helpers (existing `src/app/lib/tauri.ts`). - **Remote workspaces**: route all config reads/writes through `src/app/lib/openwork-server.ts`. - The decision happens in the stores, not the UI, so pages don’t need to branch on runtime. ### Store-level routing (concrete) - `context/extensions.ts` - `refreshSkills()` uses OpenWork server when remote, else lists local skills from FS. - `refreshPlugins()` pulls config from OpenWork server in remote mode, else reads `opencode.json` locally. - `refreshMcpServers()` reads MCP config from OpenWork server in remote mode, else from FS. - `context/workspace.ts` - Loads `openwork.json` and `opencode.json` from OpenWork server when remote. - On writes, calls OpenWork server endpoints and refreshes local state on success. - `context/commands.ts` - `list`, `create`, and `delete` commands via OpenWork server when remote. ### Action mapping (UI -> endpoint -> file) - Add plugin (Plugins page) -> `POST /workspace/:id/plugins` -> `opencode.json` `plugin` array - Remove plugin -> `DELETE /workspace/:id/plugins/:name` -> `opencode.json` `plugin` array - Add MCP server -> `POST /workspace/:id/mcp` -> `opencode.json` `mcp` map - Remove MCP -> `DELETE /workspace/:id/mcp/:name` -> `opencode.json` `mcp` map - Add skill -> `POST /workspace/:id/skills` -> `.opencode/skills//SKILL.md` - Add command -> `POST /workspace/:id/commands` -> `.opencode/commands/.md` ### UI wiring expectations - Pages call store methods without caring about local vs remote. - “Read-only” badges are derived from `openworkServerCapabilities` (e.g. missing `config.write`). - “Share config” and any write action is disabled when capabilities are absent. ### Permissions and approvals - Any write request from a remote client triggers a host approval prompt. - The host UI should show: action type, target workspace, and files to be written. - If approval is denied or times out, the server returns a clear error and the UI shows a non-blocking toast. ### Data contracts (expected formats) These map to existing OpenWork types. **Plugins** - Source of truth: `opencode.json` → `plugin` field (string or string[]). - Response shape: ```json { "items": [ { "spec": "opencode-github", "source": "config", "scope": "project" }, { "spec": "file:///path/to/plugin.js", "source": "dir.project", "scope": "project", "path": ".opencode/plugins/custom.js" } ], "loadOrder": ["config.global", "config.project", "dir.global", "dir.project"] } ``` **Skills** - Source of truth: `.opencode/skills//SKILL.md`. - Response shape: ```json { "items": [ { "name": "my-skill", "path": ".opencode/skills/my-skill", "description": "...", "scope": "project" }, { "name": "global-skill", "path": "~/.config/opencode/skills/global-skill", "description": "...", "scope": "global" } ] } ``` **MCP** - Source of truth: `opencode.json` → `mcp` object. - Response shape: ```json { "items": [ { "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"], "enabled": true }, "source": "config.project" } ] } ``` **Commands** - Source of truth: `.opencode/commands/.md` with frontmatter. - Response shape: ```json { "items": [ { "name": "daily-report", "description": "...", "template": "...", "agent": "default", "model": null, "subtask": false, "scope": "workspace" } ] } ``` **Agents** - Prefer OpenCode SDK (`listAgents`) as the primary source. - Only add OpenWork server agent endpoints if OpenCode doesn’t expose them for remote clients. --- ## Write approval flow (detailed) 1) Client sends a write request (POST/PATCH/DELETE). 2) OpenWork server emits a permission request to the host UI with: - action (write type), workspace id, list of file paths, and summary of changes 3) Host approves or denies within a timeout window. 4) Server executes the write only after approval and records an audit log entry. 5) Client receives success or `403` with a reason and shows a toast. Approval response schema (host -> server): ```json { "requestId": "...", "reply": "allow" | "deny" } ``` --- ## Config merge rules (detailed) - `opencode.json` is parsed as JSONC to preserve comments where possible. - Writes are shallow merges at top-level keys; arrays replace existing values. - Unknown keys are preserved. - On parse errors, the server returns `422` with the error location. Example: adding a plugin ```json { "plugin": ["opencode-github"] } ``` Server behavior: - Read current `opencode.json` - Normalize plugin list, append new spec if missing - Write updated JSONC back to disk --- ## Validation rules (initial) - Plugin spec: non-empty string; if duplicate, no-op. - Skill name: kebab-case; 1-64 chars; must match folder name; regex `^[a-z0-9]+(-[a-z0-9]+)*$`. - Skill description: 1-1024 chars, required in frontmatter. - MCP name: `^[A-Za-z0-9_-]+$`, cannot start with `-`. - MCP config: `type` required; for `local` require `command[]`; for `remote` require `url`. - Commands: name sanitized to `[A-Za-z0-9_-]`; template required. - Reject any path traversal (`..`) or absolute paths in payloads. --- ## Plugin handling (detailed) - `plugin` list in `opencode.json` is treated as the source of npm plugins. - Specs may be unscoped or scoped npm packages (e.g., `opencode-wakatime`, `@my-org/custom-plugin`). - Specs may also be file URLs or absolute paths when supported by OpenCode. - Local plugin files are discovered in `.opencode/plugins/` and `~/.config/opencode/plugins/`. - Only JavaScript/TypeScript files are treated as plugins (`.js`, `.ts`). - The OpenWork server should return both config plugins and local plugin files, with a `source` field: - `config` for npm specs - `dir.project` for `.opencode/plugins/` - `dir.global` for `~/.config/opencode/plugins/` - The UI can display these as separate sections while preserving OpenCode load order. - The server should not run `bun install`; OpenCode handles installs on startup. - If `.opencode/package.json` is present, note it in responses so the UI can link to dependency setup. - Plugin runtime behavior (events, custom tools, logging) remains owned by OpenCode; OpenWork server only manages config. --- ## Skill handling (detailed) - Discovery must match OpenCode: - Walk up from the workspace root to the git worktree root. - Include any `.opencode/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`. - Include global skills from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. - Validate frontmatter fields: - `name` and `description` required - `license`, `compatibility`, `metadata` optional - Enforce name and description length rules on write. - The OpenWork server does not parse or interpret skill content beyond frontmatter extraction. --- ## MCP handling (detailed) - Read `mcp` config from `opencode.json`. - Preserve `enabled`, `environment`, `headers`, `oauth`, and `timeout` fields. - If OpenCode provides remote defaults via `.well-known/opencode`, treat those as `source: "config.remote"`. - Writes always go to project `opencode.json` and should not mutate remote defaults. - If MCP tools are disabled via `tools` glob patterns, surface that as `disabledByTools: true` in responses. - OAuth tokens are managed by OpenCode and stored in `~/.local/share/opencode/mcp-auth.json`; OpenWork server should not manage tokens directly. - Authentication flows should be triggered via OpenCode (`/mcp` endpoints or CLI), not via OpenWork server. - Reference CLI flows: `opencode mcp auth `, `opencode mcp list`, `opencode mcp logout `, `opencode mcp debug `. --- ## Commands handling (detailed) - Commands are markdown files with YAML frontmatter. - The server should sanitize command names to `[A-Za-z0-9_-]` and strip leading `/`. - The command template is the body after frontmatter and is required. - Workspace scope lives under `.opencode/commands/` in the project. - Global scope lives under `~/.config/opencode/commands/` and should be disabled by default for remote clients. --- ## Path safety - All write targets are resolved under the workspace root. - The server verifies the resolved path begins with the workspace root. - Any violation returns `400` with a safe error message. --- ## Error codes - `400` invalid request payload - `401` missing/invalid token - `403` write denied or capability missing - `404` workspace not found - `409` conflict (concurrent edit detected) - `422` config parse/validation error - `500` unexpected server error --- ## Implementation checklist ### Server runtime (Bun) - Create `packages/openwork-server` with HTTP routing + JSON schema validation - Define stable port + discovery mechanism for clients - Add lifecycle hooks: start/stop/restart + health checks ### Auth + handshake - Pairing token or session key for remote clients - `GET /capabilities` endpoint to drive UI gating - CORS rules and origin allowlist for web clients ### Permissions + audit - Host approval for any write request - Audit log for config mutations (who/what/when) - Clear denial/error propagation to clients ### Filesystem writes - Workspace-root scoping + path validation - Config merge rules aligned to OpenCode - Serialization helpers for: - `.opencode/skills//SKILL.md` - `.opencode/commands/.md` (frontmatter) - `opencode.json` plugin + mcp updates ### UI wiring - Add `src/app/lib/openwork-server.ts` client - Route remote mode reads/writes through OpenWork server: - `src/app/context/extensions.ts` - `src/app/context/workspace.ts` - `src/app/pages/commands.tsx` - UI gating badges for missing capabilities ### Resilience - Retry/backoff for transient network errors - Conflict handling for concurrent writes - Friendly errors for missing workspace roots ### Packaging - Bundle Bun server as a desktop sidecar - Wire sidecar launch + permissions in Tauri config --- ## Testing strategy ### Unit tests (server) - Config merge rules (arrays replace, unknown keys preserved) - Validation rules for skill/plugin/mcp/command names - Path safety checks (reject absolute paths and path traversal) ### Filesystem tests (local) - Create a temp workspace with `.opencode/` and `opencode.json` - Verify `GET /workspace/:id/plugins` reflects: - plugin list from `opencode.json` - local plugin files in `.opencode/plugins` - global plugin files from `~/.config/opencode/plugins` (optional, behind a flag) - Verify plugin list preserves OpenCode load order metadata - Verify `GET /workspace/:id/skills` returns: - `.opencode/skills/*/SKILL.md` - `.claude/skills/*/SKILL.md` - global skills from `~/.config/opencode/skills` (optional) - Verify skill discovery respects git worktree boundary (walk up to `.git` only) - Verify skill frontmatter parsing and name/description constraints - Verify `.opencode/package.json` is detected and reported when present ### Integration tests (server + FS) - Start server against a temp workspace; verify read/write endpoints - Ensure writes only affect `.opencode` and `opencode.json` - Verify audit log entries for each write action - Validate local plugin discovery only includes `.js` and `.ts` - Validate MCP config writes preserve `enabled` and `oauth` fields ### Approval flow tests - Write request triggers approval prompt - Deny returns `403` and no file is written - Approve writes file and returns success ### Client wiring tests (OpenWork web) - Remote mode uses OpenWork server endpoints instead of Tauri FS - Missing capability switches UI to read-only - Reconnect restores write actions after capabilities return - OpenCode basic auth prompts propagate to client when enabled ### Sidecar lifecycle tests - Server starts on app launch and reports base URL - Crash triggers restart and new capabilities handshake --- ## Test cases (initial) ### Config - `GET /workspace/:id/config` returns both `opencode` and `openwork` blocks - `PATCH /workspace/:id/config` updates plugin list and preserves unknown keys - Invalid JSONC returns `422` with parse location - Remote defaults from `.well-known/opencode` appear as `source: config.remote` in responses - Writes only update project `opencode.json`, leaving remote defaults unchanged ### Plugins - Add plugin appends to list and de-dupes existing spec - Remove plugin deletes only matching spec - Invalid spec returns `400` - Local plugin files are returned with `source: dir.project` - Global plugin files are returned with `source: dir.global` when enabled - Non-js/ts files in plugin dirs are ignored ### Skills - Add skill writes `SKILL.md` with kebab-case name validation - List skills returns `name`, `path`, and `description` - Invalid skill name returns `400` - Missing frontmatter fields return `422` - Skill name mismatch with folder returns `400` ### MCP - Add MCP writes to `opencode.json` under `mcp` map - Invalid MCP name returns `400` - Remove MCP deletes entry and returns updated list - `enabled: false` is preserved in responses - `oauth: false` is preserved in responses - Missing `command` for `type: local` returns `400` - Missing `url` for `type: remote` returns `400` - `tools` glob disables MCP entries and marks them as `disabledByTools: true` ### Commands - Create command writes `.opencode/commands/.md` - Delete command removes file and returns success - Invalid template returns `400` - Name with leading `/` is sanitized ### Permissions - Denied approval returns `403` and no file changes - Approval timeout returns `403` with timeout reason ### Security - Path traversal payload returns `400` and is logged - Workspace id mismatch returns `404` ### UI - Remote client with OpenWork server: write actions enabled - Remote client without OpenWork server: write actions disabled + read-only badge --- ## Set permissions - Explicit approval for any config write originating from a remote client - Scope-limited to the active workspace root only --- ## Cover data - Audit log of config changes (who, what, when) - Optional telemetry for success/failure counts, opt-in only --- ## Map flow - Connect: remote client connects to host with capability check - View: UI shows skills/plugins/MCPs from server API - Update: user adds skill/plugin/MCP, server validates + writes config, UI refreshes --- ## Note risks - Over-expanding API surface could drift from OpenCode primitives - Mis-scoped writes could affect unrelated projects --- ## Ask questions - Which config surfaces should be writable vs read-only initially? - Should writes be batched or immediate per action? --- ## Measure success - Remote users can view skills/plugins/MCPs without filesystem access - Remote users can add a skill/plugin/MCP with a single approval --- ## Plan rollout - Phase 0: Bun server prototype running alongside OpenWork host - Phase 1: read-only APIs for skills/plugins/MCPs + config metadata - Phase 2: write APIs for skills/plugins/MCPs with audit log - Phase 3: config export/import support for remote clients - Phase 4: bundle as a first-class sidecar in desktop builds ================================================ FILE: packages/app/pr/plugin-endpoints.md ================================================ --- title: Plugin config via API description: Use /config for short-term plugin listing and add --- ## Set context OpenWork manages plugins by editing `opencode.json`, but remote workspaces cannot read that file. OpenCode already exposes `/config` for reading and updating project config, so the short-term plan is to rely on `/config` for plugin listing and adds instead of introducing a new `/plugin` endpoint right away. --- ## Define goals - List configured plugins for remote workspaces using existing `/config` - Add plugins by updating the config API in project scope - Keep behavior aligned with current config merge rules --- ## Call out non-goals - No plugin status (loaded/failed) signal - No plugin removal or update endpoints in this phase - No automatic dependency resolution or npm install workflow - No new `/plugin` endpoints in this phase - No global scope add via API (requires new endpoint) --- ## Short-term API usage GET `/config` returns the resolved config for the active workspace. ```json { "plugin": ["opencode-wakatime", "file:///path/to/plugin.js"] } ``` PATCH `/config` adds plugins by submitting the full plugin list. ```json { "plugin": ["opencode-wakatime", "opencode-github"] } ``` ```json { "plugin": ["opencode-wakatime", "opencode-github"] } ``` --- ## Shape data The plugin list is the same array of string specifiers used in config (`config.plugin`). OpenWork treats the config list as the source of truth for "installed" plugins. --- ## Persist config Project scope uses existing `Config.update()` behavior, which writes `/config.json` and disposes the instance. Global scope updates are out of scope for this short-term plan. --- ## Edge cases - `Config.update()` merges config but replaces arrays; clients must read/merge/dedupe the full plugin list before PATCH. - Updating config writes `config.json`, even if the project uses `opencode.json` or `opencode.jsonc`. - The server disposes the instance on update; clients should handle reconnects without a `reloadRequired` signal. --- ## Update SDK Expose `config.get()` and `config.update()` in the SDK for remote plugin flows. --- ## Integrate UI Use `GET /config` to populate the plugin list in remote mode. Use `PATCH /config` with a read/merge/write flow when adding plugins. Host/Tauri mode can keep using local `opencode.json` parsing. --- ## Related APIs Skills already have a dedicated endpoint (`GET /skill`), which OpenWork uses for remote listing. --- ## Log events Log `config.get` and `config.update` when plugin changes are requested. Errors include file path, parse details, and API caller identity. --- ## Plan rollout Document this as the short-term path for remote plugin support. Revisit a dedicated `/plugin` endpoint after OpenWork validates the config-based flow. ================================================ FILE: packages/app/scripts/_util.mjs ================================================ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import net from "node:net"; import { realpathSync, statSync } from "node:fs"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; export function makeClient({ baseUrl, directory }) { return createOpencodeClient({ baseUrl, directory, responseStyle: "data", throwOnError: true, }); } export async function findFreePort() { const server = net.createServer(); server.unref(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const addr = server.address(); if (!addr || typeof addr === "string") { server.close(); throw new Error("Failed to allocate a free port"); } const port = addr.port; server.close(); return port; } export async function spawnOpencodeServe({ directory, hostname = "127.0.0.1", port, corsOrigins = [], }) { assert.ok(directory && directory.trim(), "directory is required"); assert.ok(Number.isInteger(port) && port > 0, "port must be a positive integer"); const cwd = realpathSync(directory); const args = ["serve", "--hostname", hostname, "--port", String(port)]; for (const origin of corsOrigins) { args.push("--cors", origin); } const child = spawn("opencode", args, { cwd, stdio: ["ignore", "ignore", "pipe"], env: { ...process.env, // Make it explicit we're a non-TUI client. OPENCODE_CLIENT: "openwork-test", }, }); const baseUrl = `http://${hostname}:${port}`; // If the process dies early, surface stderr. let stderr = ""; child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk) => { stderr += chunk; }); async function waitForExit(ms) { return Promise.race([ once(child, "exit").then(() => true), new Promise((r) => setTimeout(() => r(false), ms)), ]); } return { cwd, baseUrl, child, async close() { if (child.exitCode !== null || child.signalCode !== null) { return; } try { child.kill("SIGTERM"); } catch { // ignore } const exited = await waitForExit(2500); if (exited) { return; } // Force kill. try { child.kill("SIGKILL"); } catch { // ignore } await waitForExit(2500); }, getStderr() { return stderr; }, }; } export async function waitForHealthy(client, { timeoutMs = 10_000, pollMs = 250 } = {}) { const start = Date.now(); let lastError; while (Date.now() - start < timeoutMs) { try { const health = await client.global.health(); assert.equal(health.healthy, true); assert.ok(typeof health.version === "string"); return health; } catch (e) { lastError = e; await new Promise((r) => setTimeout(r, pollMs)); } } const msg = lastError instanceof Error ? lastError.message : String(lastError); throw new Error(`Timed out waiting for /global/health: ${msg}`); } export function normalizeEvent(raw) { if (!raw || typeof raw !== "object") return null; if (typeof raw.type === "string") { return { type: raw.type, properties: raw.properties }; } if (raw.payload && typeof raw.payload === "object" && typeof raw.payload.type === "string") { return { type: raw.payload.type, properties: raw.payload.properties }; } return null; } export function parseArgs(argv) { const args = new Map(); for (let i = 0; i < argv.length; i++) { const item = argv[i]; if (!item.startsWith("--")) continue; const key = item.slice(2); const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true"; args.set(key, value); } return args; } export function canWriteWorkspace(directory) { try { const stat = statSync(directory); return stat && stat.isDirectory(); } catch { return false; } } ================================================ FILE: packages/app/scripts/bump-version.mjs ================================================ #!/usr/bin/env node import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; const ROOT = process.cwd(); const REPO_ROOT = path.resolve(ROOT, "../.."); const args = process.argv.slice(2); const usage = () => { console.log(`Usage: node scripts/bump-version.mjs patch|minor|major node scripts/bump-version.mjs --set x.y.z node scripts/bump-version.mjs --dry-run [patch|minor|major|--set x.y.z]`); }; const isDryRun = args.includes("--dry-run"); const filtered = args.filter((arg) => arg !== "--dry-run"); if (!filtered.length) { usage(); process.exit(1); } let mode = filtered[0]; let explicit = null; if (mode === "--set") { explicit = filtered[1] ?? null; if (!explicit) { console.error("--set requires a version like 0.1.21"); process.exit(1); } } const semverPattern = /^\d+\.\d+\.\d+$/; const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); const bump = (value, bumpMode) => { if (!semverPattern.test(value)) { throw new Error(`Invalid version: ${value}`); } const [major, minor, patch] = value.split(".").map(Number); if (bumpMode === "major") return `${major + 1}.0.0`; if (bumpMode === "minor") return `${major}.${minor + 1}.0`; if (bumpMode === "patch") return `${major}.${minor}.${patch + 1}`; throw new Error(`Unknown bump mode: ${bumpMode}`); }; const targetVersion = async () => { if (explicit) return explicit; const pkg = await readJson(path.join(ROOT, "package.json")); return bump(pkg.version, mode); }; const updatePackageJson = async (nextVersion) => { const uiPath = path.join(ROOT, "package.json"); const tauriPath = path.join(REPO_ROOT, "packages", "desktop", "package.json"); const uiData = await readJson(uiPath); const tauriData = await readJson(tauriPath); uiData.version = nextVersion; tauriData.version = nextVersion; if (!isDryRun) { await writeFile(uiPath, JSON.stringify(uiData, null, 2) + "\n"); await writeFile(tauriPath, JSON.stringify(tauriData, null, 2) + "\n"); } }; const updateCargoToml = async (nextVersion) => { const filePath = path.join(REPO_ROOT, "packages", "desktop", "src-tauri", "Cargo.toml"); const raw = await readFile(filePath, "utf8"); const updated = raw.replace(/\bversion\s*=\s*"[^"]+"/m, `version = "${nextVersion}"`); if (!isDryRun) { await writeFile(filePath, updated); } }; const updateTauriConfig = async (nextVersion) => { const filePath = path.join(REPO_ROOT, "packages", "desktop", "src-tauri", "tauri.conf.json"); const data = JSON.parse(await readFile(filePath, "utf8")); data.version = nextVersion; if (!isDryRun) { await writeFile(filePath, JSON.stringify(data, null, 2) + "\n"); } }; const main = async () => { if (explicit && !semverPattern.test(explicit)) { throw new Error(`Invalid explicit version: ${explicit}`); } if (explicit === null && !["patch", "minor", "major"].includes(mode)) { throw new Error(`Unknown mode: ${mode}`); } const nextVersion = await targetVersion(); await updatePackageJson(nextVersion); await updateCargoToml(nextVersion); await updateTauriConfig(nextVersion); console.log( JSON.stringify( { ok: true, version: nextVersion, dryRun: isDryRun, files: [ "packages/app/package.json", "packages/desktop/package.json", "packages/desktop/src-tauri/Cargo.toml", "packages/desktop/src-tauri/tauri.conf.json", ], }, null, 2, ), ); }; main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(JSON.stringify({ ok: false, error: message })); process.exit(1); }); ================================================ FILE: packages/app/scripts/e2e.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, normalizeEvent, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const requireAi = args.get("require-ai") === "true"; const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); const results = { ok: true, baseUrl: server.baseUrl, directory: server.cwd, steps: [], }; function step(name, fn) { results.steps.push({ name, status: "running" }); const idx = results.steps.length - 1; return Promise.resolve() .then(fn) .then((data) => { results.steps[idx] = { name, status: "ok", data }; }) .catch((e) => { results.ok = false; results.steps[idx] = { name, status: "error", error: e instanceof Error ? e.message : String(e), }; throw e; }); } try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await step("health", async () => { const health = await waitForHealthy(client); return health; }); await step("path.get", async () => { const path = await client.path.get(); assert.ok(typeof path.directory === "string"); return path; }); let sessionId; await step("session.create", async () => { const session = await client.session.create({ title: "OpenWork e2e" }); sessionId = session.id; assert.ok(sessionId); return { id: session.id, title: session.title }; }); await step("session.list", async () => { const sessions = await client.session.list({ limit: 50 }); assert.ok(Array.isArray(sessions)); assert.ok(sessions.some((s) => s.id === sessionId)); return { count: sessions.length }; }); await step("session.messages (initial)", async () => { const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 }); assert.ok(Array.isArray(msgs)); return { count: msgs.length }; }); await step("session.prompt noReply", async () => { await client.session.prompt({ sessionID: sessionId, noReply: true, parts: [{ type: "text", text: "OpenWork e2e context injection" }], }); const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 }); assert.ok(Array.isArray(msgs)); return { count: msgs.length }; }); await step("session.todo", async () => { const todos = await client.session.todo({ sessionID: sessionId }); assert.ok(Array.isArray(todos)); return { count: todos.length }; }); await step("event.subscribe", async () => { const controller = new AbortController(); const sub = await client.event.subscribe(undefined, { signal: controller.signal }); const events = []; const reader = (async () => { try { for await (const raw of sub.stream) { const evt = normalizeEvent(raw); if (!evt) continue; events.push(evt); if (events.length >= 10) break; } } catch { // Ignore abort errors. } })(); // Trigger events. await client.session.update({ sessionID: sessionId, title: "OpenWork e2e (updated)" }); await new Promise((r) => setTimeout(r, 1200)); controller.abort(); await Promise.race([reader, new Promise((r) => setTimeout(r, 500))]); assert.ok(events.length > 0, "expected at least one SSE event"); return { types: Array.from(new Set(events.map((e) => e.type))), sample: events.slice(0, 3), }; }); if (requireAi) { await step("AI run (optional)", async () => { // This requires provider credentials configured for the opencode server. await client.session.prompt({ sessionID: sessionId, parts: [{ type: "text", text: "Say hello in one sentence." }], }); const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 }); return { messagesCount: msgs.length }; }); } else { results.steps.push({ name: "AI run (optional)", status: "skipped", note: "Run with --require-ai true to force an actual model call.", }); } console.log(JSON.stringify(results, null, 2)); } catch (e) { const message = e instanceof Error ? e.message : String(e); results.ok = false; results.error = message; results.stderr = server.getStderr(); console.error(JSON.stringify(results, null, 2)); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/events.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, normalizeEvent, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); const events = []; const controller = new AbortController(); const sub = await client.event.subscribe(undefined, { signal: controller.signal }); const reader = (async () => { try { for await (const raw of sub.stream) { const evt = normalizeEvent(raw); if (!evt) continue; events.push(evt); if (events.length >= 25) break; } } catch { // Ignore abort errors. } })(); // Trigger something that should emit events. const created = await client.session.create({ title: "OpenWork events test" }); // Wait briefly to collect events. await new Promise((r) => setTimeout(r, 1200)); controller.abort(); await Promise.race([reader, new Promise((r) => setTimeout(r, 500))]); // We expect to see at least one server or session event. assert.ok(events.length > 0, "expected SSE events"); const types = new Set(events.map((e) => e.type)); console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, createdSessionId: created.id, eventTypes: Array.from(types), sample: events.slice(0, 5), }), ); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/fs-engine.mjs ================================================ import assert from "node:assert/strict"; import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); const root = ".openwork/test-engine"; const nestedDir = path.join(root, "nested"); const filePath = path.join(root, "hello.txt"); await mkdir(path.join(directory, nestedDir), { recursive: true }); await writeFile(path.join(directory, filePath), "openwork engine test\n", "utf8"); const entries = await client.file.list({ directory, path: root }); assert.ok(entries.some((entry) => entry.name === "nested" && entry.type === "directory")); assert.ok(entries.some((entry) => entry.name === "hello.txt" && entry.type === "file")); const read = await client.file.read({ directory, path: filePath }); assert.equal(read.type, "text"); assert.ok(read.content.includes("openwork engine test")); await rm(path.join(directory, root), { recursive: true, force: true }); console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, directory: server.cwd, root, }), ); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/health.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port, }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); const health = await waitForHealthy(client); console.log(JSON.stringify({ ok: true, baseUrl: server.baseUrl, directory: server.cwd, health })); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/permissions.mjs ================================================ import assert from "node:assert/strict"; import { writeFile } from "node:fs/promises"; import { findFreePort, makeClient, normalizeEvent, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); const requirePermission = args.get("require") === "true"; // Pick an agent name (session.shell requires it). const agents = await client.app.agents(); const agentName = agents?.[0]?.name ?? "default"; // Create a session that asks for tool permission. const session = await client.session.create({ title: "OpenWork permission test", permission: [ { permission: "bash", pattern: "*", action: "ask", }, ], }); const events = []; const controller = new AbortController(); const sub = await client.event.subscribe(undefined, { signal: controller.signal }); let asked = null; let shellError = null; let externalRead = null; const reader = (async () => { try { for await (const raw of sub.stream) { const evt = normalizeEvent(raw); if (!evt) continue; events.push(evt); if (evt.type === "permission.asked") { asked = evt; break; } } } catch { // Ignore abort errors. } })(); // Try to trigger a bash tool call without needing UI. // This endpoint requires an agent name, and may still fail if no provider/model is configured. try { await client.session.shell({ sessionID: session.id, agent: agentName, command: "pwd", }); } catch (e) { shellError = e instanceof Error ? e.message : String(e); } // Try to trigger an external-directory permission request deterministically. const externalPath = "/tmp/openwork-permission-test.txt"; await writeFile(externalPath, "openwork permission test\n", "utf8"); try { await client.file.read({ path: externalPath }); externalRead = { path: externalPath, firstAttempt: "ok" }; } catch (e) { externalRead = { path: externalPath, firstAttempt: "error", firstError: e instanceof Error ? e.message : String(e), }; } await new Promise((r) => setTimeout(r, 2200)); controller.abort(); await Promise.race([reader, new Promise((r) => setTimeout(r, 500))]); const pending = await client.permission.list(); assert.ok(Array.isArray(pending)); const reqFromEvent = asked && asked.properties && typeof asked.properties === "object" ? asked.properties : null; const reqFromList = pending.find((p) => p && p.sessionID === session.id) ?? pending[0] ?? null; const req = reqFromEvent ?? reqFromList; if (req) { assert.ok(typeof req.id === "string"); await client.permission.reply({ requestID: req.id, reply: "once" }); if (externalRead && externalRead.firstAttempt === "error") { try { await client.file.read({ path: externalRead.path }); externalRead.afterReply = "ok"; } catch (e) { externalRead.afterReply = "error"; externalRead.afterReplyError = e instanceof Error ? e.message : String(e); } } console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, sessionId: session.id, agentName, shellError, externalRead, permissionAsked: true, requestId: req.id, requestedPermission: req.permission, pendingCountBeforeReply: pending.length, observedEventTypes: Array.from(new Set(events.map((e) => e.type))).slice(0, 25), }), ); } else { if (requirePermission) { assert.fail( `No permission request observed (agent=${agentName}). shellError=${shellError ?? ""}`, ); } console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, sessionId: session.id, agentName, shellError, externalRead, permissionAsked: false, note: "No permission request observed. This usually means the server never attempted a tool call (often due to missing agent/model/provider configuration).", pendingCount: pending.length, observedEventTypes: Array.from(new Set(events.map((e) => e.type))).slice(0, 25), }), ); } } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/select-session-debug.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const baseUrlOverride = args.get("baseUrl") ?? null; const count = Number.parseInt(args.get("count") ?? "2", 10); const sessionIdOverride = args.get("session") ?? null; const withTiming = async (label, fn) => { const start = Date.now(); try { const result = await fn(); const elapsed = Date.now() - start; return { ok: true, label, elapsed, result }; } catch (error) { const elapsed = Date.now() - start; return { ok: false, label, elapsed, error: error instanceof Error ? error.message : String(error) }; } }; let server = null; try { if (!baseUrlOverride) { const port = await findFreePort(); server = await spawnOpencodeServe({ directory, port }); } const baseUrl = baseUrlOverride ?? server.baseUrl; const client = makeClient({ baseUrl, directory: server?.cwd ?? directory }); await waitForHealthy(client); console.log( JSON.stringify({ ok: true, baseUrl, directory: server?.cwd ?? directory, count, sessionIdOverride, }), ); for (let i = 0; i < count; i += 1) { console.log(`\n=== Iteration ${i + 1}/${count} ===`); const health = await withTiming("global.health", async () => client.global.health()); console.log(JSON.stringify(health)); let sessionId = sessionIdOverride; if (!sessionId) { const create = await withTiming("session.create", async () => client.session.create({ title: `Debug session ${i + 1}`, directory }), ); console.log(JSON.stringify(create)); assert.ok(create.ok, "session.create failed"); sessionId = create.result.id; } const list = await withTiming("session.list", async () => client.session.list({ limit: 50 })); console.log(JSON.stringify(list)); const messages = await withTiming("session.messages", async () => client.session.messages({ sessionID: sessionId, limit: 50 }), ); console.log(JSON.stringify(messages)); const todos = await withTiming("session.todo", async () => client.session.todo({ sessionID: sessionId })); console.log(JSON.stringify(todos)); const permissions = await withTiming("permission.list", async () => client.permission.list()); console.log(JSON.stringify(permissions)); } } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server?.getStderr?.() ?? null })); process.exitCode = 1; } finally { if (server) { await server.close(); } } ================================================ FILE: packages/app/scripts/session-switch.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); const results = { ok: true, baseUrl: server.baseUrl, directory: server.cwd, steps: [], }; function step(name, fn) { results.steps.push({ name, status: "running" }); const idx = results.steps.length - 1; return Promise.resolve() .then(fn) .then((data) => { results.steps[idx] = { name, status: "ok", data }; }) .catch((e) => { results.ok = false; results.steps[idx] = { name, status: "error", error: e instanceof Error ? e.message : String(e), }; throw e; }); } function getMessageSessionId(message) { if (message && typeof message.sessionID === "string") return message.sessionID; if (message && message.info && typeof message.info.sessionID === "string") return message.info.sessionID; return null; } function extractLastText(messages) { const list = Array.isArray(messages) ? messages.slice() : []; for (let i = list.length - 1; i >= 0; i -= 1) { const msg = list[i]; const parts = Array.isArray(msg?.parts) ? msg.parts : []; for (let p = parts.length - 1; p >= 0; p -= 1) { const part = parts[p]; if (part && part.type === "text" && typeof part.text === "string") { return part.text; } } } return null; } try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); let sessionA; let sessionB; await step("session.create A", async () => { sessionA = await client.session.create({ title: "OpenWork session A" }); assert.ok(sessionA?.id); return { id: sessionA.id }; }); await step("session.create B", async () => { sessionB = await client.session.create({ title: "OpenWork session B" }); assert.ok(sessionB?.id); return { id: sessionB.id }; }); await step("session.prompt A", async () => { await client.session.prompt({ sessionID: sessionA.id, noReply: true, parts: [{ type: "text", text: "Hello from session A" }], }); return { sessionID: sessionA.id }; }); await step("session.prompt B", async () => { await client.session.prompt({ sessionID: sessionB.id, noReply: true, parts: [{ type: "text", text: "Hello from session B" }], }); return { sessionID: sessionB.id }; }); await step("session.messages A", async () => { const messages = await client.session.messages({ sessionID: sessionA.id, limit: 50 }); assert.ok(Array.isArray(messages)); for (const msg of messages) { const msgSessionId = getMessageSessionId(msg); assert.equal(msgSessionId, sessionA.id); } const text = extractLastText(messages); assert.ok(text && text.includes("session A")); return { count: messages.length }; }); await step("session.messages B", async () => { const messages = await client.session.messages({ sessionID: sessionB.id, limit: 50 }); assert.ok(Array.isArray(messages)); for (const msg of messages) { const msgSessionId = getMessageSessionId(msg); assert.equal(msgSessionId, sessionB.id); } const text = extractLastText(messages); assert.ok(text && text.includes("session B")); return { count: messages.length }; }); await step("session.messages switch", async () => { const [messagesA, messagesB] = await Promise.all([ client.session.messages({ sessionID: sessionA.id, limit: 50 }), client.session.messages({ sessionID: sessionB.id, limit: 50 }), ]); const textA = extractLastText(messagesA); const textB = extractLastText(messagesB); assert.ok(textA && textA.includes("session A")); assert.ok(textB && textB.includes("session B")); return { aCount: messagesA.length, bCount: messagesB.length }; }); console.log(JSON.stringify(results, null, 2)); } catch (e) { const message = e instanceof Error ? e.message : String(e); results.ok = false; results.error = message; results.stderr = server.getStderr(); console.error(JSON.stringify(results, null, 2)); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/sessions-parallel.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const count = parseInt(args.get("count") ?? "5", 10); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port, }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); console.log(`Creating ${count} sessions in parallel...`); const results = await Promise.all( Array.from({ length: count }, async (_, i) => { const start = Date.now(); const label = `session-${i + 1}`; console.log(`[${label}] starting...`); try { const session = await client.session.create({ title: `Parallel session ${i + 1}` }); const elapsed = Date.now() - start; console.log(`[${label}] created in ${elapsed}ms - ${session.id}`); return { label, ok: true, elapsed, id: session.id }; } catch (err) { const elapsed = Date.now() - start; console.log(`[${label}] FAILED in ${elapsed}ms - ${err.message}`); return { label, ok: false, elapsed, error: err.message }; } }) ); const successful = results.filter((r) => r.ok); const failed = results.filter((r) => !r.ok); const times = successful.map((r) => r.elapsed); const avg = times.length ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(0) : "N/A"; const max = times.length ? Math.max(...times) : "N/A"; const min = times.length ? Math.min(...times) : "N/A"; console.log("\n--- Summary ---"); console.log(`Total: ${count}, Success: ${successful.length}, Failed: ${failed.length}`); console.log(`Times (ms): min=${min}, avg=${avg}, max=${max}`); // Now test sequential creates after the parallel burst console.log("\nNow creating 3 more sessions sequentially..."); for (let i = 0; i < 3; i++) { const start = Date.now(); const session = await client.session.create({ title: `Sequential session ${i + 1}` }); const elapsed = Date.now() - start; console.log(`[sequential-${i + 1}] created in ${elapsed}ms - ${session.id}`); } console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, parallelResults: results, stats: { count, successful: successful.length, failed: failed.length, min, avg, max }, }), ); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/sessions.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy, } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port, }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); const before = await client.session.list({ limit: 20 }); assert.ok(Array.isArray(before)); const created = await client.session.create({ title: "OpenWork test session" }); assert.ok(typeof created.id === "string"); assert.equal(created.title, "OpenWork test session"); const after = await client.session.list({ limit: 20 }); assert.ok(after.some((s) => s.id === created.id)); const messages = await client.session.messages({ sessionID: created.id, limit: 50 }); assert.ok(Array.isArray(messages)); console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, created: { id: created.id, title: created.title }, listCount: after.length, messagesCount: messages.length, }), ); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/scripts/todos.mjs ================================================ import assert from "node:assert/strict"; import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy } from "./_util.mjs"; const args = parseArgs(process.argv.slice(2)); const directory = args.get("dir") ?? process.cwd(); const port = await findFreePort(); const server = await spawnOpencodeServe({ directory, port }); try { const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); await waitForHealthy(client); const session = await client.session.create({ title: "OpenWork todos test" }); const todos = await client.session.todo({ sessionID: session.id }); assert.ok(Array.isArray(todos)); console.log( JSON.stringify({ ok: true, baseUrl: server.baseUrl, sessionId: session.id, todosCount: todos.length, todos, }), ); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); process.exitCode = 1; } finally { await server.close(); } ================================================ FILE: packages/app/src/index.tsx ================================================ /* @refresh reload */ import { render } from "solid-js/web"; import { HashRouter, Route, Router } from "@solidjs/router"; import { bootstrapTheme } from "./app/theme"; import "./app/index.css"; import AppEntry from "./app/entry"; import { PlatformProvider, type Platform } from "./app/context/platform"; import { isTauriRuntime } from "./app/utils"; bootstrapTheme(); const root = document.getElementById("root"); if (!root) { throw new Error("Root element not found"); } const RouterComponent = isTauriRuntime() ? HashRouter : Router; const platform: Platform = { platform: isTauriRuntime() ? "desktop" : "web", openLink(url: string) { if (isTauriRuntime()) { void import("@tauri-apps/plugin-opener") .then(({ openUrl }) => openUrl(url)) .catch(() => undefined); return; } window.open(url, "_blank"); }, restart: async () => { if (isTauriRuntime()) { const { relaunch } = await import("@tauri-apps/plugin-process"); await relaunch(); return; } window.location.reload(); }, notify: async (title, description, href) => { if (!("Notification" in window)) return; const permission = Notification.permission === "default" ? await Notification.requestPermission().catch(() => "denied") : Notification.permission; if (permission !== "granted") return; const inView = document.visibilityState === "visible" && document.hasFocus(); if (inView) return; await Promise.resolve() .then(() => { const notification = new Notification(title, { body: description ?? "", }); notification.onclick = () => { window.focus(); if (href) { window.history.pushState(null, "", href); window.dispatchEvent(new PopStateEvent("popstate")); } notification.close(); }; }) .catch(() => undefined); }, storage: (name) => { const prefix = name ? `${name}:` : ""; return { getItem: (key) => window.localStorage.getItem(prefix + key), setItem: (key, value) => window.localStorage.setItem(prefix + key, value), removeItem: (key) => window.localStorage.removeItem(prefix + key), }; }, fetch, }; render( () => ( null} /> ), root, ); ================================================ FILE: packages/app/src/app/command-registry.ts ================================================ import { createSignal } from "solid-js"; import type { CommandRegistryItem } from "./types"; type RegisteredCommand = CommandRegistryItem & { key: string }; export function createCommandRegistry() { const [items, setItems] = createSignal([]); let counter = 0; const registerCommand = (command: CommandRegistryItem) => { const key = `${command.id}-${counter++}`; const entry: RegisteredCommand = { ...command, key }; setItems((prev: RegisteredCommand[]) => [...prev, entry]); return () => setItems((prev: RegisteredCommand[]) => prev.filter((item) => item.key !== key)); }; const registerCommands = (commands: CommandRegistryItem[]) => { const cleanups = commands.map(registerCommand); return () => { for (const cleanup of cleanups) cleanup(); }; }; return { items, registerCommand, registerCommands, }; } ================================================ FILE: packages/app/src/app/command-state.ts ================================================ import { createMemo, createSignal, type Accessor } from "solid-js"; import type { Client, CommandDefinition, ModelRef, WorkspaceCommand } from "./types"; import { buildCommandDraft, resetCommandDraft } from "./utils/commands"; import { addOpencodeCacheHint, isTauriRuntime, parseModelRef, safeStringify } from "./utils"; import { opencodeCommandDelete, opencodeCommandList, opencodeCommandWrite } from "./lib/tauri"; import { unwrap } from "./lib/opencode"; import { t, currentLocale } from "../i18n"; import type { OpenworkServerCapabilities, OpenworkServerClient } from "./lib/openwork-server"; const COMMANDS_PATH = ".opencode/commands"; const COMMAND_FILE_SUFFIX = ".md"; const COMMAND_ARGS_RE = /\$(ARGUMENTS|\d+)/i; export const sanitizeCommandName = (value: string) => { const trimmed = value.trim().replace(/^\/+/, ""); if (!trimmed) return ""; const normalized = trimmed.replace(/\s+/g, "-"); const cleaned = normalized.replace(/[^a-zA-Z0-9_-]/g, ""); return cleaned.replace(/-+/g, "-"); }; const seedCommandName = (value: string) => sanitizeCommandName(value.toLowerCase()); /** Returns true if the name will be transformed when sanitized */ export const willSanitizeName = (value: string) => { const trimmed = value.trim().replace(/^\/+/, ""); if (!trimmed) return false; return sanitizeCommandName(value) !== trimmed; }; const commandNeedsDetails = (command: { template: string }) => COMMAND_ARGS_RE.test(command.template); export function createCommandState(options: { client: Accessor; selectedSession: Accessor<{ title?: string } | null>; prompt: Accessor; lastPromptSent: Accessor; loadSessions: (scopeRoot?: string) => Promise; selectSession: (id: string) => Promise; setSessionModelById: (value: Record | ((current: Record) => Record)) => void; setSessionAgent: (sessionId: string, agent: string | null) => void; defaultModel: Accessor; modelVariant: Accessor; setView: (view: "onboarding" | "dashboard" | "session") => void; activeWorkspaceRoot: Accessor; workspaceType: Accessor<"local" | "remote">; openworkServerClient: Accessor; openworkServerCapabilities: Accessor; openworkServerWorkspaceId: Accessor; setBusy: (value: boolean) => void; setBusyLabel: (value: string | null) => void; setBusyStartedAt: (value: number | null) => void; setError: (value: string | null) => void; }) { const [commands, setCommands] = createSignal([]); const [commandsLoaded, setCommandsLoaded] = createSignal(false); const [commandModalOpen, setCommandModalOpen] = createSignal(false); const [commandDraftName, setCommandDraftName] = createSignal(""); const [commandDraftDescription, setCommandDraftDescription] = createSignal(""); const [commandDraftTemplate, setCommandDraftTemplate] = createSignal(""); const [commandDraftScope, setCommandDraftScope] = createSignal<"workspace" | "global">("workspace"); const [runModalOpen, setRunModalOpen] = createSignal(false); const [runModalCommand, setRunModalCommand] = createSignal(null); const [runModalDetails, setRunModalDetails] = createSignal(""); // Override confirmation state const [showOverrideConfirmation, setShowOverrideConfirmation] = createSignal(false); // Track the just-saved command for scroll-to and highlight animation const [justSavedCommand, setJustSavedCommand] = createSignal<{ name: string; scope: string } | null>(null); const workspaceCommands = createMemo(() => commands().filter((c) => c.scope === "workspace")); const globalCommands = createMemo(() => commands().filter((c) => c.scope === "global")); const otherCommands = createMemo(() => commands().filter((c) => c.scope === "unknown")); function openCommandModal() { const seedTitle = options.selectedSession()?.title ?? ""; const seedTemplate = options.lastPromptSent() || options.prompt(); const nextDraft = buildCommandDraft({ seedName: seedCommandName(seedTitle), seedTemplate, scope: "workspace", }); resetCommandDraft( { setName: setCommandDraftName, setDescription: setCommandDraftDescription, setTemplate: setCommandDraftTemplate, setScope: setCommandDraftScope, }, nextDraft.scope, ); setCommandDraftName(nextDraft.name); setCommandDraftTemplate(nextDraft.template); setCommandModalOpen(true); } async function saveCommand() { const draft = buildCommandDraft({ scope: commandDraftScope() }); draft.name = commandDraftName().trim(); draft.description = commandDraftDescription().trim(); draft.template = commandDraftTemplate().trim(); const safeName = sanitizeCommandName(draft.name); if (!safeName || !draft.template) { options.setError(t("app.error.command_name_template_required", currentLocale())); return; } const isRemoteWorkspace = options.workspaceType() === "remote"; const openworkClient = options.openworkServerClient(); const openworkWorkspaceId = options.openworkServerWorkspaceId(); const openworkCapabilities = options.openworkServerCapabilities(); const canUseOpenworkServer = Boolean(openworkClient && openworkWorkspaceId && openworkCapabilities?.commands?.write); if (isRemoteWorkspace) { if (draft.scope !== "workspace") { options.setError("Global commands are only available in Host mode."); return; } if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) { options.setError("OpenWork server unavailable. Connect to save commands."); return; } } const useOpenworkServer = draft.scope === "workspace" && canUseOpenworkServer; if (!isRemoteWorkspace && !useOpenworkServer && !isTauriRuntime()) { options.setError(t("app.error.workspace_commands_desktop", currentLocale())); return; } if (!isRemoteWorkspace && !useOpenworkServer && draft.scope === "workspace" && !options.activeWorkspaceRoot().trim()) { options.setError(t("app.error.pick_workspace_folder", currentLocale())); return; } if (safeName !== draft.name) { setCommandDraftName(safeName); } // Check if a command with the same name already exists in the same scope const existingCommand = commands().find( (c) => c.name === safeName && c.scope === draft.scope ); if (existingCommand && !showOverrideConfirmation()) { // Show confirmation dialog instead of blocking setShowOverrideConfirmation(true); return; } // Reset confirmation state setShowOverrideConfirmation(false); options.setBusy(true); options.setBusyLabel( draft.scope === "workspace" ? "status.saving_workspace_command" : "status.saving_command", ); options.setBusyStartedAt(Date.now()); options.setError(null); try { const workspaceRoot = options.activeWorkspaceRoot().trim(); if (useOpenworkServer && openworkClient && openworkWorkspaceId) { await openworkClient.upsertCommand(openworkWorkspaceId, { name: safeName, description: draft.description || undefined, template: draft.template, }); } else { await opencodeCommandWrite({ scope: draft.scope, projectDir: workspaceRoot, command: { name: safeName, description: draft.description || undefined, template: draft.template, }, }); } // Directly add/update the command in local state since the SDK's // command list won't reflect the new file until app restart const newCommand: WorkspaceCommand = { name: safeName, description: draft.description || undefined, template: draft.template, scope: draft.scope, }; setCommands((current) => { // Check if command already exists (update case) const existingIndex = current.findIndex( (c) => c.name === safeName && c.scope === draft.scope ); let updated: WorkspaceCommand[]; if (existingIndex >= 0) { // Update existing command updated = [...current]; updated[existingIndex] = newCommand; } else { // Add new command updated = [...current, newCommand]; } // Keep sorted alphabetically return updated.sort((a, b) => a.name.localeCompare(b.name)); }); setJustSavedCommand({ name: safeName, scope: draft.scope }); setCommandModalOpen(false); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); options.setError(addOpencodeCacheHint(message)); } finally { options.setBusy(false); options.setBusyLabel(null); options.setBusyStartedAt(null); } } async function deleteCommand(command: WorkspaceCommand) { if (command.scope === "unknown") { options.setError(t("app.error.command_scope_unknown", currentLocale())); return; } const isRemoteWorkspace = options.workspaceType() === "remote"; const openworkClient = options.openworkServerClient(); const openworkWorkspaceId = options.openworkServerWorkspaceId(); const openworkCapabilities = options.openworkServerCapabilities(); const canUseOpenworkServer = Boolean(openworkClient && openworkWorkspaceId && openworkCapabilities?.commands?.write); if (isRemoteWorkspace) { if (command.scope !== "workspace") { options.setError("Global commands are only available in Host mode."); return; } if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) { options.setError("OpenWork server unavailable. Connect to delete commands."); return; } } const useOpenworkServer = command.scope === "workspace" && canUseOpenworkServer; if (!isRemoteWorkspace && !useOpenworkServer && !isTauriRuntime()) { options.setError(t("app.error.workspace_commands_desktop", currentLocale())); return; } if (!isRemoteWorkspace && !useOpenworkServer && command.scope === "workspace" && !options.activeWorkspaceRoot().trim()) { options.setError(t("app.error.pick_workspace_folder", currentLocale())); return; } options.setBusy(true); options.setBusyLabel("status.deleting_command"); options.setBusyStartedAt(Date.now()); options.setError(null); try { const workspaceRoot = options.activeWorkspaceRoot().trim(); if (useOpenworkServer && openworkClient && openworkWorkspaceId) { await openworkClient.deleteCommand(openworkWorkspaceId, command.name); } else { await opencodeCommandDelete({ scope: command.scope, projectDir: workspaceRoot, name: command.name, }); } await loadCommands({ workspaceRoot, quiet: true }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); options.setError(addOpencodeCacheHint(message)); } finally { options.setBusy(false); options.setBusyLabel(null); options.setBusyStartedAt(null); } } async function runCommand(command: WorkspaceCommand, details?: string) { const c = options.client(); if (!c) return; options.setBusy(true); options.setError(null); try { const session = unwrap( await c.session.create({ title: `/${command.name}`, directory: options.activeWorkspaceRoot().trim() }), ) as { id: string }; await options.loadSessions(options.activeWorkspaceRoot().trim()); await options.selectSession(session.id); options.setView("session"); const commandArgs = details?.trim() ?? ""; await c.session.command({ sessionID: session.id, command: command.name, arguments: commandArgs, }); const override = parseModelRef(command.model ?? null); const model = override ?? options.defaultModel(); options.setSessionModelById((current) => ({ ...current, [session.id]: model, })); if (command.agent?.trim()) { options.setSessionAgent(session.id, command.agent); } } catch (e) { const message = e instanceof Error ? e.message : t("app.unknown_error", currentLocale()); options.setError(addOpencodeCacheHint(message)); } finally { options.setBusy(false); } } async function loadCommands(optionsLoad?: { workspaceRoot?: string; quiet?: boolean }) { const c = options.client(); const root = (optionsLoad?.workspaceRoot ?? options.activeWorkspaceRoot()).trim(); const isRemoteWorkspace = options.workspaceType() === "remote"; const openworkClient = options.openworkServerClient(); const openworkWorkspaceId = options.openworkServerWorkspaceId(); const openworkCapabilities = options.openworkServerCapabilities(); const canUseOpenworkServer = Boolean(openworkClient && openworkWorkspaceId && openworkCapabilities?.commands?.read); if (!c) return; try { const list = unwrap(await c.command.list()) as CommandDefinition[]; let workspaceNames = new Set(); let globalNames = new Set(); if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { try { const response = await openworkClient.listCommands(openworkWorkspaceId, "workspace"); workspaceNames = new Set(response.items.map((item) => item.name)); } catch { workspaceNames = new Set(); } } if (!isRemoteWorkspace && isTauriRuntime()) { if (root) { try { const names = await opencodeCommandList({ scope: "workspace", projectDir: root }); if (!canUseOpenworkServer) { workspaceNames = new Set(names); } } catch { workspaceNames = new Set(); } } try { const names = await opencodeCommandList({ scope: "global", projectDir: root }); globalNames = new Set(names); } catch { globalNames = new Set(); } } else if (root) { try { const nodes = unwrap( await c.file.list({ directory: root, path: COMMANDS_PATH }), ) as Array<{ name: string; type: "file" | "directory"; ignored?: boolean }>; const entries = nodes.filter((n) => !n.ignored && n.type === "file"); const commandFiles = entries.filter((n) => n.name.toLowerCase().endsWith(COMMAND_FILE_SUFFIX)); workspaceNames = new Set( commandFiles.map((node) => node.name.replace(/\.md$/i, "")), ); } catch { workspaceNames = new Set(); } } const decorated = list.map((command) => ({ ...command, scope: workspaceNames.has(command.name) ? "workspace" : globalNames.has(command.name) ? "global" : "unknown", })) as WorkspaceCommand[]; decorated.sort((a, b) => a.name.localeCompare(b.name)); setCommands(decorated); setCommandsLoaded(true); } catch (e) { setCommandsLoaded(true); if (!optionsLoad?.quiet) { const message = e instanceof Error ? e.message : safeStringify(e); options.setError(addOpencodeCacheHint(message)); } } } function openRunModal(command: WorkspaceCommand) { if (!commandNeedsDetails(command)) { void runCommand(command); return; } setRunModalCommand(command); setRunModalDetails(""); setRunModalOpen(true); } async function confirmRunModal() { const command = runModalCommand(); if (!command) return; const details = runModalDetails(); setRunModalOpen(false); setRunModalCommand(null); await runCommand(command, details); } function closeRunModal() { setRunModalOpen(false); setRunModalCommand(null); } function cancelOverride() { setShowOverrideConfirmation(false); } function closeCommandModal() { setCommandModalOpen(false); setShowOverrideConfirmation(false); } function clearJustSavedCommand() { setJustSavedCommand(null); } return { commands, setCommands, commandsLoaded, setCommandsLoaded, commandModalOpen, setCommandModalOpen, commandDraftName, setCommandDraftName, commandDraftDescription, setCommandDraftDescription, commandDraftTemplate, setCommandDraftTemplate, commandDraftScope, setCommandDraftScope, runModalOpen, runModalCommand, runModalDetails, setRunModalDetails, workspaceCommands, globalCommands, otherCommands, openCommandModal, closeCommandModal, saveCommand, deleteCommand, runCommand, loadCommands, openRunModal, confirmRunModal, closeRunModal, showOverrideConfirmation, cancelOverride, justSavedCommand, clearJustSavedCommand, }; } ================================================ FILE: packages/app/src/app/constants.ts ================================================ import type { ModelRef, SuggestedPlugin } from "./types"; export const MODEL_PREF_KEY = "openwork.defaultModel"; export const SESSION_MODEL_PREF_KEY = "openwork.sessionModels"; export const THINKING_PREF_KEY = "openwork.showThinking"; export const VARIANT_PREF_KEY = "openwork.modelVariant"; export const LANGUAGE_PREF_KEY = "openwork.language"; export const KEYBIND_PREF_KEY = "openwork.keybinds"; export const DEFAULT_MODEL: ModelRef = { providerID: "opencode", modelID: "big-pickle", }; export const SUGGESTED_PLUGINS: SuggestedPlugin[] = [ { name: "opencode-scheduler", packageName: "opencode-scheduler", description: "Run scheduled jobs with the OpenCode scheduler plugin.", tags: ["automation", "jobs"], installMode: "simple", }, { name: "opencode-browser", packageName: "@different-ai/opencode-browser", description: "Browser automation with a local extension + native host.", tags: ["browser", "extension"], aliases: ["opencode-browser"], installMode: "guided", steps: [ { title: "Run the installer", description: "Installs the extension + native host and prepares the local broker.", command: "bunx @different-ai/opencode-browser@latest install", note: "Use npx @different-ai/opencode-browser@latest install if you do not have bunx.", }, { title: "Load the extension", description: "Open chrome://extensions, enable Developer mode, click Load unpacked, and select the extension folder.", url: "chrome://extensions", path: "~/.opencode-browser/extension", }, { title: "Pin the extension", description: "Pin OpenCode Browser Automation in your browser toolbar.", }, { title: "Add plugin to config", description: "Click Add to write @different-ai/opencode-browser into opencode.json.", }, ], }, ]; export type McpDirectoryInfo = { name: string; description: string; url?: string; type?: "remote" | "local"; command?: string[]; oauth: boolean; }; export const MCP_QUICK_CONNECT: McpDirectoryInfo[] = [ { name: "Notion", description: "Pages, databases, and project docs in sync.", url: "https://mcp.notion.com/mcp", type: "remote", oauth: true, }, { name: "Linear", description: "Plan sprints and ship tickets faster.", url: "https://mcp.linear.app/mcp", type: "remote", oauth: true, }, { name: "Sentry", description: "Track releases and resolve production errors.", url: "https://mcp.sentry.dev/mcp", type: "remote", oauth: true, }, { name: "Stripe", description: "Inspect payments, invoices, and subscriptions.", url: "https://mcp.stripe.com", type: "remote", oauth: true, }, { name: "HubSpot", description: "CRM notes, companies, and pipeline status.", url: "https://mcp.hubspot.com/anthropic", type: "remote", oauth: true, }, { name: "Context7", description: "Search product docs with richer context.", url: "https://mcp.context7.com/mcp", type: "remote", oauth: false, }, { name: "Chrome DevTools", description: "Drive Chrome tabs with browser automation.", type: "local", command: ["npx", "-y", "chrome-devtools-mcp@latest"], oauth: false, }, ]; ================================================ FILE: packages/app/src/app/entry.tsx ================================================ import App from "./app"; import { GlobalSDKProvider } from "./context/global-sdk"; import { GlobalSyncProvider } from "./context/global-sync"; import { LocalProvider } from "./context/local"; import { ServerProvider } from "./context/server"; export default function AppEntry() { const defaultUrl = "http://127.0.0.1:4096"; return ( ); } ================================================ FILE: packages/app/src/app/index.css ================================================ @import "tailwindcss"; @config "../../tailwind.config.ts"; @import "../styles/colors.css"; :root { color-scheme: light; } [data-theme="dark"] { color-scheme: dark; } html, body { height: 100%; } body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* Global clickable elements pointer */ button, [role="button"], a, input[type="submit"], input[type="button"], input[type="checkbox"], input[type="radio"], select { cursor: pointer; } button:disabled, [role="button"][aria-disabled="true"], input:disabled, select:disabled { cursor: not-allowed; } @utility animate-spin-slow { animation: spin 3s linear infinite; } @keyframes soft-pulse { 0%, 100% { transform: scale(1); opacity: 0.4; } 50% { transform: scale(1.15); opacity: 1; } } @utility animate-soft-pulse { animation: soft-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } /* Highlight animation for just-saved command */ @keyframes command-highlight { 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.6); border-color: rgba(99, 102, 241, 0.8); } 50% { box-shadow: 0 0 0 8px rgba(99, 102, 241, 0); border-color: rgba(99, 102, 241, 0.4); } 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); border-color: rgba(255, 255, 255, 0.08); } } .command-just-saved { animation: command-highlight 2s ease-out; border-color: rgba(99, 102, 241, 0.8); } @keyframes progress-shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(200%); } } @utility animate-progress-shimmer { animation: progress-shimmer 2s infinite linear; } ================================================ FILE: packages/app/src/app/mcp.ts ================================================ import { parse } from "jsonc-parser"; import type { McpServerConfig, McpServerEntry } from "./types"; type McpConfigValue = Record | null | undefined; export function validateMcpServerName(name: string): string { const trimmed = name.trim(); if (!trimmed) { throw new Error("server_name is required"); } if (trimmed.startsWith("-")) { throw new Error("server_name must not start with '-'"); } if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) { throw new Error("server_name must be alphanumeric with '-' or '_'"); } return trimmed; } export function parseMcpServersFromContent(content: string): McpServerEntry[] { if (!content.trim()) return []; try { const parsed = parse(content) as Record | undefined; const mcp = parsed?.mcp as McpConfigValue; if (!mcp || typeof mcp !== "object") { return []; } return Object.entries(mcp).flatMap(([name, value]) => { if (!value || typeof value !== "object") { return []; } const config = value as McpServerConfig; if (config.type !== "remote" && config.type !== "local") { return []; } return [{ name, config }]; }); } catch { return []; } } ================================================ FILE: packages/app/src/app/system-state.ts ================================================ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"; import type { Session } from "@opencode-ai/sdk/v2/client"; import type { ProviderListItem } from "./types"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import type { Client, Mode, PluginScope, ReloadReason, ResetOpenworkMode, UpdateHandle } from "./types"; import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils"; import { mapConfigProvidersToList } from "./utils/providers"; import { createUpdaterState } from "./context/updater"; import { resetOpenworkState, resetOpencodeCache } from "./lib/tauri"; import { unwrap, waitForHealthy } from "./lib/opencode"; export type NotionState = { status: Accessor<"disconnected" | "connecting" | "connected" | "error">; setStatus: (value: "disconnected" | "connecting" | "connected" | "error") => void; statusDetail: Accessor; setStatusDetail: (value: string | null) => void; skillInstalled: Accessor; setTryPromptVisible: (value: boolean) => void; }; export function createSystemState(options: { client: Accessor; mode: Accessor; sessions: Accessor; sessionStatusById: Accessor>; refreshPlugins: (scopeOverride?: PluginScope) => Promise; refreshSkills: (options?: { force?: boolean }) => Promise; refreshMcpServers?: () => Promise; reloadWorkspaceEngine?: () => Promise; setProviders: (value: ProviderListItem[]) => void; setProviderDefaults: (value: Record) => void; setProviderConnectedIds: (value: string[]) => void; setError: (value: string | null) => void; notion?: NotionState; }) { const [reloadRequired, setReloadRequired] = createSignal(false); const [reloadReasons, setReloadReasons] = createSignal([]); const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal(null); const [reloadLastFinishedAt, setReloadLastFinishedAt] = createSignal(null); const [reloadBusy, setReloadBusy] = createSignal(false); const [reloadError, setReloadError] = createSignal(null); const [cacheRepairBusy, setCacheRepairBusy] = createSignal(false); const [cacheRepairResult, setCacheRepairResult] = createSignal(null); const updater = createUpdaterState(); const { updateAutoCheck, setUpdateAutoCheck, updateStatus, setUpdateStatus, pendingUpdate, setPendingUpdate, updateEnv, setUpdateEnv, } = updater; const [resetModalOpen, setResetModalOpen] = createSignal(false); const [resetModalMode, setResetModalMode] = createSignal("onboarding"); const [resetModalText, setResetModalText] = createSignal(""); const [resetModalBusy, setResetModalBusy] = createSignal(false); const resetModalTextValue = resetModalText; const anyActiveRuns = createMemo(() => { const statuses = options.sessionStatusById(); return options.sessions().some((s) => statuses[s.id] === "running" || statuses[s.id] === "retry"); }); function clearOpenworkLocalStorage() { if (typeof window === "undefined") return; try { const keys = Object.keys(window.localStorage); for (const key of keys) { if (key.startsWith("openwork.")) { window.localStorage.removeItem(key); } } // Legacy compatibility key window.localStorage.removeItem("openwork_mode_pref"); } catch { // ignore } } function openResetModal(mode: ResetOpenworkMode) { if (anyActiveRuns()) { options.setError("Stop active runs before resetting."); return; } options.setError(null); setResetModalMode(mode); setResetModalText(""); setResetModalOpen(true); } async function confirmReset() { if (resetModalBusy()) return; if (anyActiveRuns()) { options.setError("Stop active runs before resetting."); return; } if (resetModalTextValue().trim().toUpperCase() !== "RESET") return; setResetModalBusy(true); options.setError(null); try { if (isTauriRuntime()) { await resetOpenworkState(resetModalMode()); } clearOpenworkLocalStorage(); if (isTauriRuntime()) { await relaunch(); } else { window.location.reload(); } } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); options.setError(addOpencodeCacheHint(message)); setResetModalBusy(false); } } function markReloadRequired(reason: ReloadReason) { setReloadRequired(true); setReloadLastTriggeredAt(Date.now()); setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason])); } function clearReloadRequired() { setReloadRequired(false); setReloadReasons([]); setReloadError(null); } const reloadCopy = createMemo(() => { const reasons = reloadReasons(); if (!reasons.length) { return { title: "Reload required", body: "OpenWork detected changes that require reloading the OpenCode instance.", }; } if (reasons.length === 1 && reasons[0] === "plugins") { return { title: "Reload required", body: "OpenCode loads npm plugins at startup. Reload the engine to apply opencode.json changes.", }; } if (reasons.length === 1 && reasons[0] === "skills") { return { title: "Reload required", body: "OpenCode can cache skill discovery/state. Reload the engine to make newly installed skills available.", }; } if (reasons.length === 1 && reasons[0] === "config") { return { title: "Reload required", body: "OpenCode reads opencode.json at startup. Reload the engine to apply configuration changes.", }; } if (reasons.length === 1 && reasons[0] === "mcp") { return { title: "Reload required", body: "OpenCode loads MCP servers at startup. Reload the engine to activate the new connection.", }; } return { title: "Reload required", body: "OpenWork detected OpenCode configuration changes. Reload the engine to apply them.", }; }); const canReloadEngine = createMemo(() => { if (!reloadRequired()) return false; if (!options.client()) return false; if (reloadBusy()) return false; if (options.mode() !== "host") return false; return true; }); // Keep this mounted so the reload banner UX remains in the app. createEffect(() => { reloadRequired(); }); async function reloadEngineInstance() { const initialClient = options.client(); if (!initialClient) return; if (options.mode() !== "host") { setReloadError("Reload is only available in Host mode."); return; } // if (anyActiveRuns()) { // setReloadError("Waiting for active tasks to complete before reloading."); // return; // } setReloadBusy(true); setReloadError(null); try { if (options.reloadWorkspaceEngine) { const ok = await options.reloadWorkspaceEngine(); if (ok === false) { setReloadError("Failed to reload the engine."); return; } } else { unwrap(await initialClient.instance.dispose()); } const nextClient = options.client(); if (!nextClient) { throw new Error("OpenCode client unavailable after reload."); } await waitForHealthy(nextClient, { timeoutMs: 12_000 }); try { const providerList = unwrap(await nextClient.provider.list()); options.setProviders(providerList.all); options.setProviderDefaults(providerList.default); options.setProviderConnectedIds(providerList.connected); } catch { try { const cfg = unwrap(await nextClient.config.providers()); options.setProviders(mapConfigProvidersToList(cfg.providers)); options.setProviderDefaults(cfg.default); options.setProviderConnectedIds([]); } catch { options.setProviders([]); options.setProviderDefaults({}); options.setProviderConnectedIds([]); } } await options.refreshPlugins("project").catch(() => undefined); await options.refreshSkills({ force: true }).catch(() => undefined); await options.refreshMcpServers?.().catch(() => undefined); if (options.notion) { let nextStatus = options.notion.status(); if (nextStatus === "connecting") { nextStatus = "connected"; options.notion.setStatus(nextStatus); } if (nextStatus === "connected") { options.notion.setStatusDetail(options.notion.statusDetail() ?? "Workspace connected"); } try { window.localStorage.setItem("openwork.notionStatus", nextStatus); if (nextStatus === "connected" && options.notion.statusDetail()) { window.localStorage.setItem("openwork.notionStatusDetail", options.notion.statusDetail() || ""); } } catch { // ignore } } clearReloadRequired(); if (options.notion && options.notion.status() === "connected" && options.notion.skillInstalled()) { options.notion.setTryPromptVisible(true); } } catch (e) { setReloadError(e instanceof Error ? e.message : safeStringify(e)); } finally { setReloadBusy(false); setReloadLastFinishedAt(Date.now()); } } async function reloadWorkspaceEngine() { await reloadEngineInstance(); } async function repairOpencodeCache() { if (!isTauriRuntime()) { setCacheRepairResult("Cache repair requires the desktop app."); return; } if (cacheRepairBusy()) return; setCacheRepairBusy(true); setCacheRepairResult(null); options.setError(null); try { const result = await resetOpencodeCache(); if (result.errors.length) { setCacheRepairResult(result.errors[0]); return; } if (result.removed.length) { setCacheRepairResult("OpenCode cache repaired. Restart the engine if it was running."); } else { setCacheRepairResult("No OpenCode cache found. Nothing to repair."); } } catch (e) { setCacheRepairResult(e instanceof Error ? e.message : safeStringify(e)); } finally { setCacheRepairBusy(false); } } async function checkForUpdates(optionsCheck?: { quiet?: boolean }) { if (!isTauriRuntime()) return; const env = updateEnv(); if (env && !env.supported) { if (!optionsCheck?.quiet) { setUpdateStatus({ state: "error", lastCheckedAt: updateStatus().state === "idle" ? (updateStatus() as { state: "idle"; lastCheckedAt: number | null }).lastCheckedAt : null, message: env.reason ?? "Updates are not supported in this environment.", }); } return; } const prev = updateStatus(); setUpdateStatus({ state: "checking", startedAt: Date.now() }); try { const update = (await check({ timeout: 8_000 })) as unknown as UpdateHandle | null; const checkedAt = Date.now(); if (!update) { setPendingUpdate(null); setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt }); return; } const notes = typeof update.body === "string" ? update.body : undefined; setPendingUpdate({ update, version: update.version, notes }); setUpdateStatus({ state: "available", lastCheckedAt: checkedAt, version: update.version, date: update.date, notes, }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); if (optionsCheck?.quiet) { setUpdateStatus(prev); return; } setPendingUpdate(null); setUpdateStatus({ state: "error", lastCheckedAt: null, message }); } } async function downloadUpdate() { const pending = pendingUpdate(); if (!pending) return; options.setError(null); const state = updateStatus(); const lastCheckedAt = state.state === "available" ? state.lastCheckedAt : Date.now(); setUpdateStatus({ state: "downloading", lastCheckedAt, version: pending.version, totalBytes: null, downloadedBytes: 0, notes: pending.notes, }); try { await pending.update.download((event: any) => { if (!event || typeof event !== "object") return; const record = event as Record; setUpdateStatus((current) => { if (current.state !== "downloading") return current; if (record.event === "Started") { const total = record.data && typeof record.data.contentLength === "number" ? record.data.contentLength : null; return { ...current, totalBytes: total }; } if (record.event === "Progress") { const chunk = record.data && typeof record.data.chunkLength === "number" ? record.data.chunkLength : 0; return { ...current, downloadedBytes: current.downloadedBytes + chunk }; } return current; }); }); setUpdateStatus({ state: "ready", lastCheckedAt, version: pending.version, notes: pending.notes, }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); setUpdateStatus({ state: "error", lastCheckedAt, message }); } } async function installUpdateAndRestart() { const pending = pendingUpdate(); if (!pending) return; if (anyActiveRuns()) { options.setError("Stop active runs before installing an update."); return; } options.setError(null); try { await pending.update.install(); await pending.update.close(); await relaunch(); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); setUpdateStatus({ state: "error", lastCheckedAt: null, message }); } } return { reloadRequired, reloadReasons, reloadLastTriggeredAt, reloadLastFinishedAt, setReloadLastFinishedAt, reloadBusy, reloadError, reloadCopy, canReloadEngine, markReloadRequired, clearReloadRequired, reloadEngineInstance, reloadWorkspaceEngine, cacheRepairBusy, cacheRepairResult, repairOpencodeCache, updateAutoCheck, setUpdateAutoCheck, updateStatus, setUpdateStatus, pendingUpdate, setPendingUpdate, updateEnv, setUpdateEnv, checkForUpdates, downloadUpdate, installUpdateAndRestart, resetModalOpen, setResetModalOpen, resetModalMode, setResetModalMode, resetModalText: resetModalTextValue, setResetModalText, resetModalBusy, openResetModal, confirmReset, anyActiveRuns, }; } ================================================ FILE: packages/app/src/app/theme.ts ================================================ export type ThemeMode = "light" | "dark" | "system"; const THEME_PREF_KEY = "openwork.themePref"; const mediaQuery = "(prefers-color-scheme: dark)"; const getMediaQueryList = () => typeof window === "undefined" ? null : window.matchMedia(mediaQuery); const readStoredMode = (): ThemeMode => { if (typeof window === "undefined") return "system"; try { const stored = window.localStorage.getItem(THEME_PREF_KEY); if (stored === "light" || stored === "dark" || stored === "system") { return stored; } } catch { // ignore } return "system"; }; const resolveMode = (mode: ThemeMode) => { if (mode !== "system") return mode; return getMediaQueryList()?.matches ? "dark" : "light"; }; const applyTheme = (mode: ThemeMode) => { if (typeof document === "undefined") return; const resolved = resolveMode(mode); document.documentElement.dataset.theme = resolved; document.documentElement.style.colorScheme = resolved; }; export const bootstrapTheme = () => { const mode = readStoredMode(); applyTheme(mode); }; export const getInitialThemeMode = () => readStoredMode(); export const persistThemeMode = (mode: ThemeMode) => { if (typeof window === "undefined") return; try { window.localStorage.setItem(THEME_PREF_KEY, mode); } catch { // ignore } }; export const subscribeToSystemTheme = (onChange: (isDark: boolean) => void) => { const list = getMediaQueryList(); if (!list) return () => undefined; const handler = (event: MediaQueryListEvent) => onChange(event.matches); list.addEventListener("change", handler); return () => list.removeEventListener("change", handler); }; export const applyThemeMode = (mode: ThemeMode) => { applyTheme(mode); }; ================================================ FILE: packages/app/src/app/types.ts ================================================ import type { Message, Part, PermissionRequest as ApiPermissionRequest, ProviderListResponse, Session, } from "@opencode-ai/sdk/v2/client"; import type { createClient } from "./lib/opencode"; import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/tauri"; export type Client = ReturnType; export type ProviderListItem = ProviderListResponse["all"][number]; export type PlaceholderAssistantMessage = { id: string; sessionID: string; role: "assistant"; time: { created: number; completed?: number; }; parentID: string; modelID: string; providerID: string; mode: string; agent: string; path: { cwd: string; root: string; }; cost: number; tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number; }; }; }; export type MessageInfo = Message | PlaceholderAssistantMessage; export type MessageWithParts = { info: MessageInfo; parts: Part[]; }; export type MessageGroup = | { kind: "text"; part: Part } | { kind: "steps"; id: string; parts: Part[] }; export type PromptMode = "prompt" | "shell"; export type ComposerPart = | { type: "text"; text: string } | { type: "agent"; name: string } | { type: "file"; path: string; label?: string }; export type ComposerAttachment = { id: string; name: string; mimeType: string; size: number; kind: "image" | "file"; dataUrl: string; }; export type ComposerDraft = { mode: PromptMode; parts: ComposerPart[]; attachments: ComposerAttachment[]; text: string; }; export type ArtifactItem = { id: string; name: string; path?: string; kind: "file" | "text"; size?: string; messageId?: string; }; export type OpencodeEvent = { type: string; properties?: unknown; }; export type View = "onboarding" | "dashboard" | "session" | "proto"; export type Mode = "host" | "client"; export type OnboardingStep = "mode" | "host" | "client" | "connecting"; export type DashboardTab = "home" | "sessions" | "commands" | "skills" | "plugins" | "mcp" | "settings"; export type SettingsTab = "general" | "model" | "keybinds" | "advanced" | "remote" | "messaging" | "debug"; export type WorkspacePreset = "starter" | "automation" | "minimal"; export type ResetOpenworkMode = "onboarding" | "all"; export type CommandScope = "workspace" | "global" | "unknown"; export type CommandRegistryScope = "global" | "session"; export type CommandTriggerContext = { source?: "palette" | "slash" | "keybind"; }; export type CommandRegistryItem = { id: string; title: string; category?: string; description?: string; keybind?: string; slash?: string; scope?: CommandRegistryScope; showInPalette?: boolean; onSelect: (context?: CommandTriggerContext) => void; onHighlight?: (context?: CommandTriggerContext) => void; }; export type CommandDefinition = { name: string; description?: string; template: string; agent?: string; model?: string; subtask?: boolean; }; export type WorkspaceCommand = CommandDefinition & { scope: CommandScope; }; export type WorkspaceOpenworkConfig = { version: number; workspace?: { name?: string | null; createdAt?: number | null; preset?: string | null; } | null; authorizedRoots: string[]; }; export type SkillCard = { name: string; path: string; description?: string; }; export type PluginInstallStep = { title: string; description: string; command?: string; url?: string; path?: string; note?: string; }; export type SuggestedPlugin = { name: string; packageName: string; description: string; tags: string[]; aliases?: string[]; installMode?: "simple" | "guided"; steps?: PluginInstallStep[]; }; export type PluginScope = "project" | "global"; export type McpServerConfig = { type: "remote" | "local"; url?: string; command?: string[]; enabled?: boolean; headers?: Record; environment?: Record; oauth?: Record | false; timeout?: number; }; export type McpServerEntry = { name: string; config: McpServerConfig; }; export type McpStatus = | { status: "connected" } | { status: "disabled" } | { status: "failed"; error: string } | { status: "needs_auth" } | { status: "needs_client_registration"; error: string }; export type McpStatusMap = Record; export type ReloadReason = "plugins" | "skills" | "mcp" | "config"; export type PendingPermission = ApiPermissionRequest & { receivedAt: number; }; export type TodoItem = { id: string; content: string; status: string; priority: string; }; export type ModelRef = { providerID: string; modelID: string; }; export type ModelOption = { providerID: string; modelID: string; title: string; description?: string; footer?: string; disabled?: boolean; isFree: boolean; isConnected: boolean; }; export type SelectedSessionSnapshot = { session: Session | null; status: string; modelLabel: string; }; export type WorkspaceState = { active: WorkspaceInfo | null; path: string; root: string; }; export type PluginState = { scope: PluginScope; config: OpencodeConfigFile | null; list: string[]; }; export type CommandState = { items: WorkspaceCommand[]; loaded: boolean; }; export type WorkspaceDisplay = WorkspaceInfo & { name: string; }; export type UpdateHandle = { available: boolean; currentVersion: string; version: string; date?: string; body?: string; rawJson: Record; close: () => Promise; download: (onEvent?: (event: any) => void) => Promise; install: () => Promise; downloadAndInstall: (onEvent?: (event: any) => void) => Promise; }; ================================================ FILE: packages/app/src/app/components/button.tsx ================================================ import { splitProps } from "solid-js"; import type { JSX } from "solid-js"; type ButtonProps = JSX.ButtonHTMLAttributes & { variant?: "primary" | "secondary" | "ghost" | "outline" | "danger"; }; export default function Button(props: ButtonProps) { const [local, rest] = splitProps(props, ["variant", "class", "disabled", "title", "type"]); const variant = () => local.variant ?? "primary"; const base = "inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-200 active:scale-95 focus:outline-none focus:ring-2 focus:ring-gray-6/15 disabled:opacity-50 disabled:cursor-not-allowed"; const variants: Record, string> = { primary: "bg-gray-12 text-gray-1 hover:bg-gray-11 shadow-lg shadow-gray-12/5", secondary: "bg-gray-4 text-gray-12 hover:bg-gray-5 border border-gray-7/50", ghost: "bg-transparent text-gray-11 hover:text-gray-12 hover:bg-gray-4/50", outline: "border border-gray-7 text-gray-11 hover:border-gray-7 bg-transparent", danger: "bg-red-7/10 text-red-11 hover:bg-red-7/20 border border-red-7/20", }; return (