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
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>OpenWork</title>

    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
    <script>
      (function () {
        try {
          var mode = localStorage.getItem("openwork.themePref") || "system";
          var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
          var resolved = mode === "dark" || (mode === "system" && prefersDark) ? "dark" : "light";
          document.documentElement.dataset.theme = resolved;
          document.documentElement.style.colorScheme = resolved;
        } catch (e) {
          document.documentElement.dataset.theme = "light";
          document.documentElement.style.colorScheme = "light";
        }
      })();
    </script>
  </head>
  <body class="bg-gray-12 text-gray-12">
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>



================================================
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)://<host>:<port>`
**Auth:** `Authorization: Bearer <token>`

### `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 <token>`)
- 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/<name>/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/<name>.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/<name>/SKILL.md`
  - `.claude/skills/<name>/SKILL.md`
- Skill discovery paths (global):
  - `~/.config/opencode/skills/<name>/SKILL.md`
  - `~/.claude/skills/<name>/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 <token>
```
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 <token>`.
- 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/<name>/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<Capabilities>;
  listWorkspaces(): Promise<WorkspaceInfo[]>;
  getConfig(id: string): Promise<{ opencode: object; openwork: object }>;
  patchConfig(id: string, body: { opencode?: object; openwork?: object }): Promise<void>;
  listPlugins(id: string): Promise<string[]>;
  addPlugin(id: string, spec: string): Promise<string[]>;
  removePlugin(id: string, name: string): Promise<string[]>;
  listSkills(id: string): Promise<SkillCard[]>;
  upsertSkill(id: string, payload: { name: string; content: string }): Promise<SkillCard>;
  listMcp(id: string): Promise<McpServerEntry[]>;
  addMcp(id: string, payload: { name: string; config: McpServerConfig }): Promise<McpServerEntry[]>;
  removeMcp(id: string, name: string): Promise<McpServerEntry[]>;
  listCommands(id: string): Promise<WorkspaceCommand[]>;
  upsertCommand(id: string, payload: WorkspaceCommand): Promise<WorkspaceCommand[]>;
  deleteCommand(id: string, name: string): Promise<void>;
};
```

### 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/<name>/SKILL.md`
- Add command -> `POST /workspace/:id/commands` -> `.opencode/commands/<name>.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/<name>/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/<name>.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 <name>`, `opencode mcp list`, `opencode mcp logout <name>`, `opencode mcp debug <name>`.

---
## 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/<name>/SKILL.md`
  - `.opencode/commands/<name>.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/<name>.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 `<workspace>/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 ?? "<none>"}`,
      );
    }

    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(
  () => (
    <PlatformProvider value={platform}>
      <RouterComponent root={AppEntry}>
        <Route path="*all" component={() => null} />
      </RouterComponent>
    </PlatformProvider>
  ),
  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<RegisteredCommand[]>([]);
  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<Client | null>;
  selectedSession: Accessor<{ title?: string } | null>;
  prompt: Accessor<string>;
  lastPromptSent: Accessor<string>;
  loadSessions: (scopeRoot?: string) => Promise<void>;
  selectSession: (id: string) => Promise<void>;
  setSessionModelById: (value: Record<string, ModelRef> | ((current: Record<string, ModelRef>) => Record<string, ModelRef>)) => void;
  setSessionAgent: (sessionId: string, agent: string | null) => void;
  defaultModel: Accessor<ModelRef>;
  modelVariant: Accessor<string | null>;
  setView: (view: "onboarding" | "dashboard" | "session") => void;
  activeWorkspaceRoot: Accessor<string>;
  workspaceType: Accessor<"local" | "remote">;
  openworkServerClient: Accessor<OpenworkServerClient | null>;
  openworkServerCapabilities: Accessor<OpenworkServerCapabilities | null>;
  openworkServerWorkspaceId: Accessor<string | null>;
  setBusy: (value: boolean) => void;
  setBusyLabel: (value: string | null) => void;
  setBusyStartedAt: (value: number | null) => void;
  setError: (value: string | null) => void;
}) {
  const [commands, setCommands] = createSignal<WorkspaceCommand[]>([]);
  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<WorkspaceCommand | null>(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<string>();
      let globalNames = new Set<string>();

      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 (
    <ServerProvider defaultUrl={defaultUrl}>
      <GlobalSDKProvider>
        <GlobalSyncProvider>
          <LocalProvider>
            <App />
          </LocalProvider>
        </GlobalSyncProvider>
      </GlobalSDKProvider>
    </ServerProvider>
  );
}



================================================
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<string, unknown> | 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<string, unknown> | 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<string | null>;
  setStatusDetail: (value: string | null) => void;
  skillInstalled: Accessor<boolean>;
  setTryPromptVisible: (value: boolean) => void;
};

export function createSystemState(options: {
  client: Accessor<Client | null>;
  mode: Accessor<Mode | null>;
  sessions: Accessor<Session[]>;
  sessionStatusById: Accessor<Record<string, string>>;
  refreshPlugins: (scopeOverride?: PluginScope) => Promise<void>;
  refreshSkills: (options?: { force?: boolean }) => Promise<void>;
  refreshMcpServers?: () => Promise<void>;
  reloadWorkspaceEngine?: () => Promise<boolean>;
  setProviders: (value: ProviderListItem[]) => void;
  setProviderDefaults: (value: Record<string, string>) => void;
  setProviderConnectedIds: (value: string[]) => void;
  setError: (value: string | null) => void;
  notion?: NotionState;
}) {
  const [reloadRequired, setReloadRequired] = createSignal(false);
  const [reloadReasons, setReloadReasons] = createSignal<ReloadReason[]>([]);
  const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal<number | null>(null);
  const [reloadLastFinishedAt, setReloadLastFinishedAt] = createSignal<number | null>(null);
  const [reloadBusy, setReloadBusy] = createSignal(false);
  const [reloadError, setReloadError] = createSignal<string | null>(null);

  const [cacheRepairBusy, setCacheRepairBusy] = createSignal(false);
  const [cacheRepairResult, setCacheRepairResult] = createSignal<string | null>(null);

  const updater = createUpdaterState();
  const {
    updateAutoCheck,
    setUpdateAutoCheck,
    updateStatus,
    setUpdateStatus,
    pendingUpdate,
    setPendingUpdate,
    updateEnv,
    setUpdateEnv,
  } = updater;

  const [resetModalOpen, setResetModalOpen] = createSignal(false);
  const [resetModalMode, setResetModalMode] = createSignal<ResetOpenworkMode>("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<string, any>;

        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<typeof createClient>;

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<string, string>;
  environment?: Record<string, string>;
  oauth?: Record<string, string> | 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<string, McpStatus>;

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<string, unknown>;
  close: () => Promise<void>;
  download: (onEvent?: (event: any) => void) => Promise<void>;
  install: () => Promise<void>;
  downloadAndInstall: (onEvent?: (event: any) => void) => Promise<void>;
};



================================================
FILE: packages/app/src/app/components/button.tsx
================================================
import { splitProps } from "solid-js";
import type { JSX } from "solid-js";

type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
  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<NonNullable<ButtonProps["variant"]>, 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 (
    <button
      {...rest}
      type={local.type ?? "button"}
      disabled={local.disabled}
      aria-disabled={local.disabled}
      title={local.title}
      class={`${base} ${variants[variant()]} ${local.class ?? ""}`.trim()}
    />
  );
}



================================================
FILE: packages/app/src/app/components/card.tsx
================================================
import type { JSX } from "solid-js";

type CardProps = {
  title?: string;
  children: JSX.Element;
  actions?: JSX.Element;
};

export default function Card(props: CardProps) {
  return (
    <div class="rounded-2xl bg-gray-2/50 border border-gray-6/60 backdrop-blur-xl shadow-[0_20px_80px_rgba(0,0,0,0.55)]">
      {props.title || props.actions ? (
        <div class="flex items-center justify-between gap-3 border-b border-gray-6/70 px-5 py-4">
          <div class="text-sm font-semibold text-gray-12">{props.title}</div>
          <div>{props.actions}</div>
        </div>
      ) : null}
      <div class="px-5 py-4">{props.children}</div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/command-modal.tsx
================================================
import { createMemo, Show } from "solid-js";

import { AlertTriangle, X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import Button from "./button";
import ConfirmModal from "./confirm-modal";
import { sanitizeCommandName, willSanitizeName } from "../command-state";

export type CommandModalProps = {
  open: boolean;
  name: string;
  description: string;
  template: string;
  scope: "workspace" | "global";
  showOverrideConfirmation: boolean;
  onClose: () => void;
  onSave: () => void;
  onCancelOverride: () => void;
  onNameChange: (value: string) => void;
  onDescriptionChange: (value: string) => void;
  onTemplateChange: (value: string) => void;
  onScopeChange: (value: "workspace" | "global") => void;
};

export default function CommandModal(props: CommandModalProps) {
  const translate = (key: string) => t(key, currentLocale());

  const sanitizedName = createMemo(() => sanitizeCommandName(props.name));
  const showSanitizedHint = createMemo(() => willSanitizeName(props.name));

  return (
    <>
      <Show when={props.open}>
        <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
          <div class="bg-gray-2 border border-gray-6/70 w-full max-w-xl rounded-2xl shadow-2xl overflow-hidden">
            <div class="p-6">
              <div class="flex items-start justify-between gap-4">
                <div>
                  <h3 class="text-lg font-semibold text-gray-12">
                    {translate("commands.modal_title")}
                  </h3>
                  <p class="text-sm text-gray-11 mt-1">
                    {translate("commands.modal_description")}
                  </p>
                </div>
                <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
                  <X size={16} />
                </Button>
              </div>

              <div class="mt-6 space-y-4">
                <label class="block">
                  <div class="mb-1 flex items-center justify-between gap-2">
                    <span class="text-xs font-medium text-gray-11 shrink-0">{translate("commands.name_label")}</span>
                    <span
                      class="text-xs text-amber-11 flex items-center gap-1 h-4 min-w-0 group relative"
                      classList={{ invisible: !showSanitizedHint() }}
                    >
                      <AlertTriangle size={12} class="shrink-0" />
                      <span class="truncate">
                        {translate("commands.name_will_be")} <code class="bg-gray-4 px-1 rounded">{sanitizedName()}</code>
                      </span>
                      <span class="absolute right-0 top-full mt-1 px-2 py-1 bg-gray-3 border border-gray-6 rounded text-xs text-gray-12 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity delay-100 pointer-events-none z-10">
                        {sanitizedName()}
                      </span>
                    </span>
                  </div>
                  <input
                    class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
                    value={props.name}
                    onInput={(event) => props.onNameChange(event.currentTarget.value)}
                    placeholder={translate("commands.name_placeholder")}
                  />
                  <div class="mt-1 text-xs text-gray-10">{translate("commands.name_hint")}</div>
                </label>

                <label class="block">
                  <div class="mb-1 text-xs font-medium text-gray-11">{translate("commands.description_label")}</div>
                  <input
                    class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
                    value={props.description}
                    onInput={(event) => props.onDescriptionChange(event.currentTarget.value)}
                    placeholder={translate("commands.description_placeholder")}
                  />
                </label>

                <div class="grid grid-cols-2 gap-2">
                  <button
                    class={`px-3 py-2 rounded-xl border text-sm transition-colors ${
                      props.scope === "workspace"
                        ? "bg-gray-12/10 text-gray-12 border-gray-6/20"
                        : "text-gray-11 border-gray-6 hover:text-gray-12"
                    }`}
                    onClick={() => props.onScopeChange("workspace")}
                    type="button"
                  >
                    {translate("commands.workspace")}
                  </button>
                  <button
                    class={`px-3 py-2 rounded-xl border text-sm transition-colors ${
                      props.scope === "global"
                        ? "bg-gray-12/10 text-gray-12 border-gray-6/20"
                        : "text-gray-11 border-gray-6 hover:text-gray-12"
                    }`}
                    onClick={() => props.onScopeChange("global")}
                    type="button"
                  >
                    {translate("commands.global")}
                  </button>
                </div>

                <label class="block">
                  <div class="mb-1 text-xs font-medium text-gray-11">{translate("commands.template_label")}</div>
                  <textarea
                    class="w-full min-h-40 rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
                    value={props.template}
                    onInput={(event) => props.onTemplateChange(event.currentTarget.value)}
                    placeholder={translate("commands.template_placeholder")}
                  />
                  <div class="mt-1 text-xs text-gray-10">{translate("commands.template_hint")}</div>
                </label>
              </div>

              <div class="mt-6 flex justify-end gap-2">
                <Button variant="outline" onClick={props.onClose}>
                  {translate("common.cancel")}
                </Button>
                <Button onClick={props.onSave}>{translate("common.save")}</Button>
              </div>
            </div>
          </div>
        </div>
      </Show>

      <ConfirmModal
        open={props.showOverrideConfirmation}
        title={translate("commands.override_title")}
        message={translate("commands.override_warning").replace("{name}", sanitizedName())}
        confirmLabel={translate("commands.override_confirm")}
        cancelLabel={translate("commands.override_cancel")}
        variant="warning"
        onConfirm={props.onSave}
        onCancel={props.onCancelOverride}
      />
    </>
  );
}



================================================
FILE: packages/app/src/app/components/command-palette-modal.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { Search, X } from "lucide-solid";

import Button from "./button";
import { formatKeybindLabel } from "../utils/keybinds";

export type PaletteItem = {
  id: string;
  title: string;
  description?: string;
  category?: string;
  keybind?: string;
  onSelect: () => void;
  onHighlight?: () => void;
};

export type PaletteGroup = {
  id: string;
  title: string;
  items: PaletteItem[];
};

export type CommandPaletteModalProps = {
  open: boolean;
  mode: "command" | "file";
  query: string;
  setQuery: (value: string) => void;
  groups: PaletteGroup[];
  onClose: () => void;
};

export default function CommandPaletteModal(props: CommandPaletteModalProps) {
  let searchInputRef: HTMLInputElement | undefined;
  const [activeIndex, setActiveIndex] = createSignal(0);

  const filteredGroups = createMemo(() => {
    const query = props.query.trim().toLowerCase();
    if (!query) return props.groups;
    return props.groups
      .map((group: PaletteGroup) => {
        const items = group.items.filter((item) => {
          const haystack = `${item.title} ${item.description ?? ""} ${item.category ?? ""}`.toLowerCase();
          return haystack.includes(query);
        });
        return { ...group, items };
      })
      .filter((group) => group.items.length > 0);
  });

  const flattenedItems = createMemo(() => filteredGroups().flatMap((group) => group.items));

  createEffect(() => {
    if (props.open) {
      setActiveIndex(0);
      requestAnimationFrame(() => searchInputRef?.focus());
    }
  });

  createEffect(() => {
    const item = flattenedItems()[activeIndex()];
    item?.onHighlight?.();
  });

  createEffect(() => {
    if (!props.open) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (!props.open) return;
      if (event.key === "Escape") {
        event.preventDefault();
        event.stopPropagation();
        props.onClose();
        return;
      }
      if (event.key === "ArrowDown") {
        event.preventDefault();
        event.stopPropagation();
        setActiveIndex((current: number) => Math.min(current + 1, flattenedItems().length - 1));
        return;
      }
      if (event.key === "ArrowUp") {
        event.preventDefault();
        event.stopPropagation();
        setActiveIndex((current: number) => Math.max(current - 1, 0));
        return;
      }
      if (event.key === "Enter") {
        const item = flattenedItems()[activeIndex()];
        if (!item) return;
        event.preventDefault();
        event.stopPropagation();
        item.onSelect();
        props.onClose();
      }
    };
    window.addEventListener("keydown", onKeyDown, true);
    onCleanup(() => window.removeEventListener("keydown", onKeyDown, true));
  });

  const placeholder = () =>
    props.mode === "file" ? "Search files" : "Search commands or files";

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-xl rounded-2xl shadow-2xl overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col">
          <div class="p-5 border-b border-gray-6/50 flex items-center gap-3">
            <Search size={16} class="text-gray-10" />
            <input
              ref={(el) => (searchInputRef = el)}
              type="text"
              value={props.query}
              onInput={(event: InputEvent) => props.setQuery((event.currentTarget as HTMLInputElement).value)}
              placeholder={placeholder()}
              class="flex-1 bg-transparent text-sm text-gray-12 placeholder-gray-7 focus:outline-none"
            />
            <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
              <X size={16} />
            </Button>
          </div>

          <div class="p-4 space-y-5 overflow-y-auto">
            <Show
              when={filteredGroups().length}
              fallback={<div class="text-sm text-gray-9">No results found.</div>}
            >
              <For each={filteredGroups()}>
                {(group: PaletteGroup) => (
                  <div class="space-y-2">
                    <div class="text-[11px] font-semibold uppercase tracking-wider text-gray-9">
                      {group.title}
                    </div>
                    <div class="space-y-1">
                      <For each={group.items}>
                        {(item: PaletteItem) => {
                          const isActive = createMemo(
                            () => flattenedItems()[activeIndex()]?.id === item.id,
                          );
                          return (
                            <button
                              type="button"
                              class={`w-full flex items-start justify-between gap-3 rounded-xl px-3 py-2 text-left transition-colors ${
                                isActive()
                                  ? "bg-gray-12/10 text-gray-12"
                                  : "text-gray-11 hover:bg-gray-12/5"
                              }`}
                              onMouseEnter={() => {
                                const index = flattenedItems().findIndex((entry) => entry.id === item.id);
                                if (index >= 0) setActiveIndex(index);
                                item.onHighlight?.();
                              }}
                              onMouseDown={(event: MouseEvent) => {
                                event.preventDefault();
                                item.onSelect();
                                props.onClose();
                              }}
                            >
                              <div class="min-w-0">
                                <div class="text-sm font-medium text-gray-12 truncate">{item.title}</div>
                                <Show when={item.description}>
                                  <div class="text-[11px] text-gray-9 truncate">{item.description}</div>
                                </Show>
                              </div>
                              <Show when={item.keybind}>
                                <span class="text-[10px] text-gray-9 uppercase tracking-wide border border-gray-6/60 rounded-lg px-2 py-1">
                                  {formatKeybindLabel(item.keybind!)}
                                </span>
                              </Show>
                            </button>
                          );
                        }}
                      </For>
                    </div>
                  </div>
                )}
              </For>
            </Show>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/command-run-modal.tsx
================================================
import { Show, createEffect } from "solid-js";
import { X } from "lucide-solid";
import type { WorkspaceCommand } from "../types";
import { t, currentLocale } from "../../i18n";

import Button from "./button";
import TextInput from "./text-input";

export type CommandRunModalProps = {
  open: boolean;
  command: WorkspaceCommand | null;
  details: string;
  onDetailsChange: (value: string) => void;
  onClose: () => void;
  onRun: () => void;
};

export default function CommandRunModal(props: CommandRunModalProps) {
  let inputRef: HTMLInputElement | undefined;
  const translate = (key: string) => t(key, currentLocale());
  const name = () => props.command?.name ?? "";
  const description = () => props.command?.description ?? "";

  const handleKeyDown = (event: KeyboardEvent) => {
    if (event.key === "Enter") {
      event.preventDefault();
      props.onRun();
    }
  };

  createEffect(() => {
    if (props.open && props.command) {
      requestAnimationFrame(() => inputRef?.focus());
    }
  });

  return (
    <Show when={props.open && props.command}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
          <div class="p-6">
            <div class="flex items-start justify-between gap-4">
              <div>
                <h3 class="text-lg font-semibold text-gray-12">{translate("commands.run_modal_title")}</h3>
                <p class="text-sm text-gray-11 mt-1">{translate("commands.run_modal_description")}</p>
              </div>
              <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
                <X size={16} />
              </Button>
            </div>

            <div class="mt-6 space-y-4">
              <div class="rounded-xl border border-gray-6/50 bg-gray-2/40 px-4 py-3">
                <div class="text-xs text-gray-10 uppercase tracking-wide">{translate("commands.command_label")}</div>
                <div class="text-sm font-medium text-gray-12 mt-1">/{name()}</div>
                <Show when={description()}>
                  <div class="text-xs text-gray-10 mt-1">{description()}</div>
                </Show>
              </div>

              <TextInput
                ref={inputRef}
                label={translate("commands.details_label")}
                value={props.details}
                onInput={(event) => props.onDetailsChange(event.currentTarget.value)}
                onKeyDown={handleKeyDown}
                placeholder={translate("commands.details_placeholder")}
                hint={translate("commands.details_hint")}
              />
            </div>

            <div class="mt-6 flex justify-end gap-2">
              <Button variant="outline" onClick={props.onClose}>
                {translate("common.cancel")}
              </Button>
              <Button onClick={props.onRun}>{translate("commands.run_modal_run")}</Button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/confirm-modal.tsx
================================================
import { Show, type JSX } from "solid-js";

import { AlertTriangle } from "lucide-solid";

import Button from "./button";

export type ConfirmModalProps = {
  open: boolean;
  title: string;
  message: string | JSX.Element;
  confirmLabel: string;
  cancelLabel: string;
  variant?: "danger" | "warning";
  onConfirm: () => void;
  onCancel: () => void;
};

export default function ConfirmModal(props: ConfirmModalProps) {
  const variant = () => props.variant ?? "warning";

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-[60] bg-gray-1/70 backdrop-blur-sm flex items-center justify-center p-4">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
          <div class="p-6">
            <div class="flex items-start gap-4">
              <div
                class="shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
                classList={{
                  "bg-amber-3/50 text-amber-11": variant() === "warning",
                  "bg-red-3/50 text-red-11": variant() === "danger",
                }}
              >
                <AlertTriangle size={20} />
              </div>
              <div class="min-w-0">
                <h3 class="text-base font-semibold text-gray-12">{props.title}</h3>
                <p class="mt-2 text-sm text-gray-11">{props.message}</p>
              </div>
            </div>

            <div class="mt-6 flex justify-end gap-2">
              <Button variant="outline" onClick={props.onCancel}>
                {props.cancelLabel}
              </Button>
              <Button
                variant={variant() === "danger" ? "danger" : "primary"}
                onClick={props.onConfirm}
              >
                {props.confirmLabel}
              </Button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/create-remote-workspace-modal.tsx
================================================
import { Show, createEffect, createMemo, createSignal } from "solid-js";

import { Globe, X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import Button from "./button";
import TextInput from "./text-input";

export default function CreateRemoteWorkspaceModal(props: {
  open: boolean;
  onClose: () => void;
  onConfirm: (input: {
    openworkHostUrl?: string | null;
    openworkToken?: string | null;
    opencodeBaseUrl?: string | null;
    directory?: string | null;
    displayName?: string | null;
  }) => void;
  submitting?: boolean;
  inline?: boolean;
  showClose?: boolean;
  title?: string;
  subtitle?: string;
  confirmLabel?: string;
}) {
  let inputRef: HTMLInputElement | undefined;
  const translate = (key: string) => t(key, currentLocale());

  const [remoteType, setRemoteType] = createSignal<"openwork" | "opencode">("opencode");
  const [openworkHostUrl, setOpenworkHostUrl] = createSignal("");
  const [openworkToken, setOpenworkToken] = createSignal("");
  const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
  const [opencodeBaseUrl, setOpencodeBaseUrl] = createSignal("");
  const [directory, setDirectory] = createSignal("");
  const [displayName, setDisplayName] = createSignal("");

  const showClose = () => props.showClose ?? true;
  const title = () => props.title ?? translate("dashboard.create_remote_workspace_title");
  const subtitle = () => props.subtitle ?? translate("dashboard.create_remote_workspace_subtitle");
  const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_remote_workspace_confirm");
  const isInline = () => props.inline ?? false;
  const submitting = () => props.submitting ?? false;

  const canSubmit = createMemo(() => {
    if (submitting()) return false;
    if (remoteType() === "openwork") return openworkHostUrl().trim().length > 0;
    return opencodeBaseUrl().trim().length > 0;
  });

  createEffect(() => {
    if (props.open) {
      requestAnimationFrame(() => inputRef?.focus());
    }
  });

  createEffect(() => {
    if (!props.open) return;
    setRemoteType("opencode");
    setOpenworkHostUrl("");
    setOpenworkToken("");
    setOpenworkTokenVisible(false);
    setOpencodeBaseUrl("");
    setDirectory("");
    setDisplayName("");
  });

  const content = (
    <div class="bg-gray-2 border border-gray-6 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
      <div class="p-6 border-b border-gray-6 flex justify-between items-center bg-gray-1">
        <div>
          <h3 class="font-semibold text-gray-12 text-lg">{title()}</h3>
          <p class="text-gray-10 text-sm">{subtitle()}</p>
        </div>
        <Show when={showClose()}>
          <button
            onClick={props.onClose}
            disabled={submitting()}
            class={`hover:bg-gray-4 p-1 rounded-full ${submitting() ? "opacity-50 cursor-not-allowed" : ""}`.trim()}
          >
            <X size={20} class="text-gray-10" />
          </button>
        </Show>
      </div>

      <div class="p-6 flex-1 overflow-y-auto space-y-6">
        <div class="rounded-2xl border border-gray-6 bg-gray-1/40 p-4 flex items-center gap-3">
          <div class="w-10 h-10 rounded-xl bg-gray-3 flex items-center justify-center">
            <Globe size={20} class="text-gray-12" />
          </div>
          <div>
            <div class="text-sm font-medium text-gray-12">{translate("dashboard.remote_workspace_title")}</div>
            <div class="text-xs text-gray-10">{translate("dashboard.remote_workspace_hint")}</div>
          </div>
        </div>

        <div class="space-y-4">
          <div class="grid grid-cols-2 gap-2 rounded-xl border border-gray-6 bg-gray-1/60 p-1">
            <button
              type="button"
              onClick={() => setRemoteType("openwork")}
              class={`text-xs px-3 py-2 rounded-lg transition-colors ${
                remoteType() === "openwork"
                  ? "bg-gray-12/10 text-gray-12"
                  : "text-gray-10 hover:text-gray-12"
              }`}
            >
              {translate("dashboard.remote_mode_openwork_alpha")}
            </button>
            <button
              type="button"
              onClick={() => setRemoteType("opencode")}
              class={`text-xs px-3 py-2 rounded-lg transition-colors ${
                remoteType() === "opencode"
                  ? "bg-gray-12/10 text-gray-12"
                  : "text-gray-10 hover:text-gray-12"
              }`}
            >
              {translate("dashboard.remote_mode_direct")}
            </button>
          </div>

          <Show when={remoteType() === "openwork"}>
            <div class="space-y-4">
              <TextInput
                ref={inputRef}
                label={translate("dashboard.openwork_host_label")}
                placeholder={translate("dashboard.openwork_host_placeholder")}
                value={openworkHostUrl()}
                onInput={(event) => setOpenworkHostUrl(event.currentTarget.value)}
                hint={translate("dashboard.openwork_host_hint")}
                disabled={submitting()}
              />

              <label class="block">
                <div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
                <div class="flex items-center gap-2">
                  <input
                    type={openworkTokenVisible() ? "text" : "password"}
                    value={openworkToken()}
                    onInput={(event) => setOpenworkToken(event.currentTarget.value)}
                    placeholder={translate("dashboard.openwork_host_token_placeholder")}
                    disabled={submitting()}
                    class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
                  />
                  <Button
                    variant="outline"
                    class="text-xs h-9 px-3 shrink-0"
                    onClick={() => setOpenworkTokenVisible((prev) => !prev)}
                    disabled={submitting()}
                  >
                    {openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
                  </Button>
                </div>
                <div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
              </label>
            </div>
          </Show>

          <Show when={remoteType() === "opencode"}>
            <TextInput
              ref={inputRef}
              label={translate("dashboard.remote_base_url_label")}
              placeholder={translate("dashboard.remote_base_url_placeholder")}
              value={opencodeBaseUrl()}
              onInput={(event) => setOpencodeBaseUrl(event.currentTarget.value)}
              disabled={submitting()}
            />
          </Show>

          <TextInput
            label={translate("dashboard.remote_directory_label")}
            placeholder={translate("dashboard.remote_directory_placeholder")}
            value={directory()}
            onInput={(event) => setDirectory(event.currentTarget.value)}
            hint={translate("dashboard.remote_directory_hint")}
            disabled={submitting()}
          />
          <TextInput
            label={translate("dashboard.remote_display_name_label")}
            placeholder={translate("dashboard.remote_display_name_placeholder")}
            value={displayName()}
            onInput={(event) => setDisplayName(event.currentTarget.value)}
            disabled={submitting()}
          />
        </div>
      </div>

      <div class="p-6 border-t border-gray-6 bg-gray-1 flex justify-end gap-3">
        <Show when={showClose()}>
          <Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
            {translate("common.cancel")}
          </Button>
        </Show>
        <Button
          onClick={() =>
            props.onConfirm({
              openworkHostUrl: remoteType() === "openwork" ? openworkHostUrl().trim() : null,
              openworkToken: remoteType() === "openwork" ? openworkToken().trim() : null,
              opencodeBaseUrl: remoteType() === "opencode" ? opencodeBaseUrl().trim() : null,
              directory: directory().trim() ? directory().trim() : null,
              displayName: displayName().trim() ? displayName().trim() : null,
            })
          }
          disabled={!canSubmit()}
          title={
            remoteType() === "openwork"
              ? !openworkHostUrl().trim()
                ? translate("dashboard.remote_base_url_required")
                : undefined
              : !opencodeBaseUrl().trim()
                ? translate("dashboard.remote_base_url_required")
                : undefined
          }
        >
          {confirmLabel()}
        </Button>
      </div>
    </div>
  );

  return (
    <Show when={props.open || isInline()}>
      <div
        class={
          isInline()
            ? "w-full"
            : "fixed inset-0 z-50 flex items-center justify-center bg-gray-1/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"
        }
      >
        {content}
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/create-workspace-modal.tsx
================================================
import { For, Show, createEffect, createSignal } from "solid-js";

import { CheckCircle2, FolderPlus, Loader2, X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import Button from "./button";

export default function CreateWorkspaceModal(props: {
  open: boolean;
  onClose: () => void;
  onConfirm: (preset: "starter" | "automation" | "minimal", folder: string | null) => void;
  onPickFolder: () => Promise<string | null>;
  submitting?: boolean;
  inline?: boolean;
  showClose?: boolean;
  title?: string;
  subtitle?: string;
  confirmLabel?: string;
}) {
  let pickFolderRef: HTMLButtonElement | undefined;
  const translate = (key: string) => t(key, currentLocale());

  const [preset, setPreset] = createSignal<"starter" | "automation" | "minimal">("starter");
  const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null);
  const [pickingFolder, setPickingFolder] = createSignal(false);

  createEffect(() => {
    if (props.open) {
      requestAnimationFrame(() => pickFolderRef?.focus());
    }
  });

  const options = () => [
    {
      id: "starter" as const,
      name: translate("dashboard.starter_workspace"),
      desc: translate("dashboard.starter_workspace_desc"),
    },
    {
      id: "minimal" as const,
      name: translate("dashboard.empty_workspace"),
      desc: translate("dashboard.empty_workspace_desc"),
    },
  ];

  const folderLabel = () => {
    const folder = selectedFolder();
    if (!folder) return translate("dashboard.choose_folder");
    const parts = folder.replace(/\\/g, "/").split("/").filter(Boolean);
    return parts[parts.length - 1] ?? folder;
  };

  const folderSubLabel = () => {
    const folder = selectedFolder();
    if (!folder) return translate("dashboard.choose_folder_next");
    return folder;
  };

  const handlePickFolder = async () => {
    if (pickingFolder()) return;
    setPickingFolder(true);
    try {
      await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
      const next = await props.onPickFolder();
      if (next) {
        setSelectedFolder(next);
      }
    } finally {
      setPickingFolder(false);
    }
  };

  const showClose = () => props.showClose ?? true;
  const title = () => props.title ?? translate("dashboard.create_workspace_title");
  const subtitle = () => props.subtitle ?? translate("dashboard.create_workspace_subtitle");
  const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_workspace_confirm");
  const isInline = () => props.inline ?? false;
  const submitting = () => props.submitting ?? false;

  const content = (
    <div class="bg-gray-2 border border-gray-6 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
      <div class="p-6 border-b border-gray-6 flex justify-between items-center bg-gray-1">
        <div>
          <h3 class="font-semibold text-gray-12 text-lg">{title()}</h3>
          <p class="text-gray-10 text-sm">{subtitle()}</p>
        </div>
        <Show when={showClose()}>
          <button
            onClick={props.onClose}
            disabled={submitting()}
            class={`hover:bg-gray-4 p-1 rounded-full ${submitting() ? "opacity-50 cursor-not-allowed" : ""}`.trim()}
          >
            <X size={20} class="text-gray-10" />
          </button>
        </Show>
      </div>

          <div class="p-6 flex-1 overflow-y-auto space-y-8">
            <div class="space-y-4">
              <div class="flex items-center gap-3 text-sm font-medium text-gray-12">
                <div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">
                  1
                </div>
                {translate("dashboard.select_folder")}
              </div>
              <div class="ml-9">
                <button
                  type="button"
                  ref={pickFolderRef}
                  onClick={handlePickFolder}
                  disabled={pickingFolder() || submitting()}
                  class={`w-full border border-dashed border-gray-7 bg-gray-2/50 rounded-xl p-4 text-left transition ${
                    pickingFolder() ? "opacity-70 cursor-wait" : "hover:border-gray-7"
                  }`.trim()}
                >
                  <div class="flex items-center gap-3 text-gray-12">
                    <FolderPlus size={20} class="text-gray-11" />
                    <div class="flex-1 min-w-0">
                      <div class="text-sm font-medium text-gray-12 truncate">{folderLabel()}</div>
                      <div class="text-xs text-gray-10 font-mono truncate mt-1">{folderSubLabel()}</div>
                    </div>
                    <Show
                      when={pickingFolder()}
                      fallback={<span class="text-xs text-gray-10">{translate("dashboard.change")}</span>}
                    >
                      <span class="flex items-center gap-2 text-xs text-gray-10">
                        <Loader2 size={12} class="animate-spin" />
                        {translate("dashboard.opening")}
                      </span>
                    </Show>
                  </div>
                </button>
              </div>
            </div>

            <div class="space-y-4">
              <div class="flex items-center gap-3 text-sm font-medium text-gray-12">
                <div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">
                  2
                </div>
                {translate("dashboard.choose_preset")}
              </div>
              <div class={`ml-9 grid gap-3 ${!selectedFolder() ? "opacity-50" : ""}`.trim()}>
                <For each={options()}>
                  {(opt) => (
                    <div
                      onClick={() => {
                        if (!selectedFolder()) return;
                        if (submitting()) return;
                        setPreset(opt.id);
                      }}
                      class={`p-4 rounded-xl border cursor-pointer transition-all ${
                        preset() === opt.id
                          ? "bg-indigo-7/10 border-indigo-7/50"
                          : "bg-gray-2 border-gray-6 hover:border-gray-7"
                      } ${!selectedFolder() || submitting() ? "pointer-events-none" : ""}`.trim()}
                    >
                      <div class="flex justify-between items-start">
                        <div>
                          <div
                            class={`font-medium text-sm ${
                              preset() === opt.id ? "text-indigo-11" : "text-gray-12"
                            }`}
                          >
                            {opt.name}
                          </div>
                          <div class="text-xs text-gray-10 mt-1">{opt.desc}</div>
                        </div>
                        <Show when={preset() === opt.id}>
                          <CheckCircle2 size={16} class="text-indigo-6" />
                        </Show>
                      </div>
                    </div>
                  )}
                </For>
              </div>
            </div>
          </div>

      <div class="p-6 border-t border-gray-6 bg-gray-1 flex justify-end gap-3">
        <Show when={showClose()}>
          <Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
              {translate("common.cancel")}
          </Button>
        </Show>
        <Button
          onClick={() => props.onConfirm(preset(), selectedFolder())}
          disabled={!selectedFolder() || submitting()}
          title={!selectedFolder() ? translate("dashboard.choose_folder_continue") : undefined}
        >
          <Show
            when={submitting()}
            fallback={confirmLabel()}
          >
            <span class="inline-flex items-center gap-2">
              <Loader2 size={16} class="animate-spin" />
              Creating...
            </span>
          </Show>
        </Button>
      </div>
    </div>
  );

  return (
    <Show when={props.open || isInline()}>
      <div
        class={
          isInline()
            ? "w-full"
            : "fixed inset-0 z-50 flex items-center justify-center bg-gray-1/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"
        }
      >
        {content}
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/flyout-item.tsx
================================================
import { Show, createSignal, onMount } from "solid-js";
import { Check, FileText, Folder } from "lucide-solid";

export type FlyoutProps = {
  item: {
    id: string;
    rect: { top: number; left: number; width: number; height: number };
    targetRect: { top: number; left: number; width: number; height: number };
    label: string;
    icon: "file" | "check" | "folder";
  };
};

export default function FlyoutItem(props: FlyoutProps) {
  const [active, setActive] = createSignal(false);
  onMount(() => {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        setActive(true);
      });
    });
  });

  return (
    <div
      class="fixed z-[100] pointer-events-none transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] flex items-center gap-2 px-3 py-2 rounded-xl bg-gray-12 text-gray-1 shadow-xl border border-gray-11/20"
      style={{
        top: `${props.item.rect.top}px`,
        left: `${props.item.rect.left}px`,
        transform: active()
          ? `translate(${props.item.targetRect.left - props.item.rect.left}px, ${
              props.item.targetRect.top - props.item.rect.top
            }px) scale(0.3)`
          : "translate(0, 0) scale(1)",
        opacity: active() ? 0 : 1,
      }}
    >
      <Show when={props.item.icon === "check"}>
        <Check size={14} />
      </Show>
      <Show when={props.item.icon === "file"}>
        <FileText size={14} />
      </Show>
      <Show when={props.item.icon === "folder"}>
        <Folder size={14} />
      </Show>
      <span class="text-xs font-medium truncate max-w-[120px]">{props.item.label}</span>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/language-picker-modal.tsx
================================================
import { For, Show } from "solid-js";
import { CheckCircle2, Circle } from "lucide-solid";
import { LANGUAGE_OPTIONS, type Language, t, currentLocale } from "../../i18n";

export type LanguagePickerModalProps = {
  open: boolean;
  currentLanguage: Language;
  onSelect: (language: Language) => void;
  onClose: () => void;
};

export default function LanguagePickerModal(props: LanguagePickerModalProps) {
  const translate = (key: string) => t(key, currentLocale());

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
        <div class="bg-zinc-900 rounded-2xl p-6 w-full max-w-md border border-zinc-800 shadow-xl">
          <h3 class="text-lg font-medium text-white mb-4">{translate("settings.language")}</h3>

          <div class="space-y-2">
            <For each={LANGUAGE_OPTIONS}>
              {(option) => (
                <button
                  class={`w-full p-3 rounded-xl text-left transition-all ${
                    props.currentLanguage === option.value
                      ? "bg-zinc-800 text-white border-2 border-zinc-700"
                      : "bg-zinc-950 text-zinc-400 hover:bg-zinc-900 border-2 border-transparent"
                  }`}
                  onClick={() => {
                    props.onSelect(option.value);
                    props.onClose();
                  }}
                >
                  <div class="flex items-center justify-between gap-2">
                    <div class="flex-1">
                      <div class="font-medium text-sm">{option.nativeName}</div>
                      <Show when={option.label !== option.nativeName}>
                        <div class="text-xs text-zinc-500 mt-0.5">{option.label}</div>
                      </Show>
                    </div>
                    <div class="text-zinc-500">
                      <Show
                        when={props.currentLanguage === option.value}
                        fallback={<Circle size={14} />}
                      >
                        <CheckCircle2 size={14} class="text-emerald-400" />
                      </Show>
                    </div>
                  </div>
                </button>
              )}
            </For>
          </div>

          <button
            class="mt-4 w-full py-2 text-sm text-zinc-500 hover:text-white transition-colors"
            onClick={props.onClose}
          >
            {translate("common.cancel")}
          </button>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/mcp-auth-modal.tsx
================================================
import { Show, createEffect, createSignal, on, onCleanup } from "solid-js";
import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-solid";
import Button from "./button";
import TextInput from "./text-input";
import type { Client } from "../types";
import type { McpDirectoryInfo } from "../constants";
import { unwrap } from "../lib/opencode";
import { validateMcpServerName } from "../mcp";
import { t, type Language } from "../../i18n";
import { isTauriRuntime } from "../utils";

export type McpAuthModalProps = {
  open: boolean;
  onClose: () => void;
  onComplete: () => void | Promise<void>;
  onReloadEngine?: () => void | Promise<void>;
  reloadRequired?: boolean;
  reloadBlocked?: boolean;
  isRemoteWorkspace?: boolean;
  client: Client | null;
  entry: McpDirectoryInfo | null;
  projectDir: string;
  language: Language;
};

export default function McpAuthModal(props: McpAuthModalProps) {
  const translate = (key: string, replacements?: Record<string, string>) => {
    let result = t(key, props.language);
    if (replacements) {
      Object.entries(replacements).forEach(([placeholder, value]) => {
        result = result.replace(`{${placeholder}}`, value);
      });
    }
    return result;
  };

  const [loading, setLoading] = createSignal(false);
  const [error, setError] = createSignal<string | null>(null);
  const [needsReload, setNeedsReload] = createSignal(false);
  const [alreadyConnected, setAlreadyConnected] = createSignal(false);
  const [authInProgress, setAuthInProgress] = createSignal(false);
  const [statusChecking, setStatusChecking] = createSignal(false);
  const [reloadNotice, setReloadNotice] = createSignal<string | null>(null);
  const [authorizationUrl, setAuthorizationUrl] = createSignal<string | null>(null);
  const [callbackInput, setCallbackInput] = createSignal("");
  const [manualAuthBusy, setManualAuthBusy] = createSignal(false);

  let statusPoll: number | null = null;

  const stopStatusPolling = () => {
    if (statusPoll !== null) {
      window.clearInterval(statusPoll);
      statusPoll = null;
    }
  };

  onCleanup(() => stopStatusPolling());

  const openAuthorizationUrl = async (url: string) => {
    if (isTauriRuntime()) {
      const { openUrl } = await import("@tauri-apps/plugin-opener");
      await openUrl(url);
      return;
    }

    if (typeof window !== "undefined") {
      window.open(url, "_blank", "noopener,noreferrer");
    }
  };

  const fetchMcpStatus = async (slug: string) => {
    const entry = props.entry;
    const client = props.client;
    if (!entry || !client) return null;

    try {
      const result = await client.mcp.status({ directory: props.projectDir });
      const status = result.data?.[slug] as { status?: string; error?: string } | undefined;
      return status ?? null;
    } catch {
      return null;
    }
  };

  const startStatusPolling = (slug: string) => {
    if (typeof window === "undefined") return;
    stopStatusPolling();
    let attempts = 0;
    statusPoll = window.setInterval(async () => {
      attempts += 1;
      if (attempts > 20) {
        stopStatusPolling();
        return;
      }

      const status = await fetchMcpStatus(slug);
      if (status?.status === "connected") {
        setAlreadyConnected(true);
        stopStatusPolling();
      }
    }, 2000);
  };

  const startAuth = async (forceRetry = false) => {
    const entry = props.entry;
    const client = props.client;

    if (!entry || !client) return;

    const isRemoteWorkspace = !!props.isRemoteWorkspace;

    let slug = "";
    try {
      const safeName = validateMcpServerName(entry.name);
      slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
    } catch (err) {
      const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
      setError(message);
      setLoading(false);
      setAuthInProgress(false);
      return;
    }

    if (!forceRetry && authInProgress()) {
      return;
    }

    setError(null);
    setNeedsReload(false);
    setAlreadyConnected(false);
    stopStatusPolling();
    setAuthorizationUrl(null);
    setCallbackInput("");
    setReloadNotice(null);
    setLoading(true);
    setAuthInProgress(true);

    try {
      if (props.reloadRequired) {
        setNeedsReload(true);
        setReloadNotice(
          props.reloadBlocked
            ? translate("mcp.auth.reload_blocked")
            : translate("mcp.auth.reload_notice")
        );
        return;
      }

      const statusEntry = await fetchMcpStatus(slug);
      if (statusEntry?.status === "connected") {
        setAlreadyConnected(true);
        return;
      }

      if (!isRemoteWorkspace) {
        const result = await client.mcp.auth.authenticate({
          name: slug,
          directory: props.projectDir,
        });
        const status = unwrap(result) as { status?: string; error?: string };

        if (status.status === "connected") {
          setAlreadyConnected(true);
          await props.onComplete();
          return;
        }

        if (status.status === "needs_client_registration") {
          setError(status.error ?? translate("mcp.auth.client_registration_required"));
        } else if (status.status === "disabled") {
          setError(translate("mcp.auth.server_disabled"));
        } else if (status.status === "failed") {
          setError(status.error ?? translate("mcp.auth.oauth_failed"));
        } else {
          setError(translate("mcp.auth.authorization_still_required"));
        }
        return;
      }

      const authResult = await client.mcp.auth.start({
        name: slug,
        directory: props.projectDir,
      });
      const auth = unwrap(authResult) as { authorizationUrl?: string };

      if (!auth.authorizationUrl) {
        setAlreadyConnected(true);
        return;
      }

      setAuthorizationUrl(auth.authorizationUrl);
      await openAuthorizationUrl(auth.authorizationUrl);
      startStatusPolling(slug);
    } catch (err) {
      const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");

      if (message.toLowerCase().includes("does not support oauth")) {
        const serverSlug = props.entry?.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "server";
        if (props.reloadRequired) {
          setReloadNotice(
            props.reloadBlocked
              ? translate("mcp.auth.reload_blocked")
              : translate("mcp.auth.reload_notice")
          );
        } else {
          setError(
            `${message}\n\n` + translate("mcp.auth.oauth_not_supported_hint", { server: serverSlug })
          );
        }
        setNeedsReload(true);
      } else if (message.toLowerCase().includes("not found") || message.toLowerCase().includes("unknown")) {
        setNeedsReload(true);
        setError(translate("mcp.auth.try_reload_engine", { message }));
      } else {
        setError(message);
      }
    } finally {
      setLoading(false);
      setAuthInProgress(false);
    }
  };

  // Start the OAuth flow when modal opens with an entry
  createEffect(
    on(
      () => [props.open, props.entry, props.client] as const,
      ([isOpen, entry, client]) => {
        if (!isOpen || !entry || !client) {
          return;
        }
        // Only start auth on initial open, not on every prop change
        startAuth(false);
      },
      { defer: true } // Defer to avoid double-firing on mount
    )
  );

  const handleRetry = () => {
    startAuth(true);
  };

  const handleReloadAndRetry = async () => {
    if (!props.onReloadEngine) return;
    await props.onReloadEngine();
    startAuth(true);
  };

  const handleClose = () => {
    setError(null);
    setLoading(false);
    setAlreadyConnected(false);
    setNeedsReload(false);
    setAuthInProgress(false);
    setStatusChecking(false);
    setAuthorizationUrl(null);
    setCallbackInput("");
    setManualAuthBusy(false);
    setReloadNotice(null);
    stopStatusPolling();
    props.onClose();
  };

  const isBusy = () => loading() || statusChecking() || manualAuthBusy();

  const parseAuthCode = (value: string) => {
    const trimmed = value.trim();
    if (!trimmed) return null;

    const match = trimmed.match(/[?&]code=([^&]+)/);
    if (match) {
      try {
        return decodeURIComponent(match[1]);
      } catch {
        return match[1];
      }
    }

    if (/^https?:\/\//i.test(trimmed) || trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) {
      return null;
    }

    return trimmed;
  };

  const handleManualComplete = async () => {
    const entry = props.entry;
    const client = props.client;
    if (!entry || !client) return;

    let slug = "";
    try {
      const safeName = validateMcpServerName(entry.name);
      slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
    } catch (err) {
      const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
      setError(message);
      return;
    }

    const code = parseAuthCode(callbackInput());
    if (!code) {
      setError(translate("mcp.auth.callback_invalid"));
      return;
    }

    setManualAuthBusy(true);
    setError(null);
    stopStatusPolling();

    try {
      const result = await client.mcp.auth.callback({
        name: slug,
        directory: props.projectDir,
        code,
      });
      const status = unwrap(result) as { status?: string; error?: string };
      if (status.status === "connected") {
        setAlreadyConnected(true);
        setManualAuthBusy(false);
        await props.onComplete();
        return;
      }

      if (status.status === "needs_client_registration") {
        setError(status.error ?? translate("mcp.auth.client_registration_required"));
      } else if (status.status === "disabled") {
        setError(translate("mcp.auth.server_disabled"));
      } else if (status.status === "failed") {
        setError(status.error ?? translate("mcp.auth.oauth_failed"));
      } else {
        setError(translate("mcp.auth.authorization_still_required"));
      }
    } catch (err) {
      const message = err instanceof Error ? err.message : translate("mcp.auth.oauth_failed");
      setError(message);
    } finally {
      setManualAuthBusy(false);
    }
  };

  const handleComplete = async () => {
    const entry = props.entry;
    const client = props.client;
    if (!entry || !client) return;

    setError(null);
    setStatusChecking(true);

    let slug = "";
    try {
      const safeName = validateMcpServerName(entry.name);
      slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
    } catch (err) {
      const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
      setError(message);
      setStatusChecking(false);
      return;
    }

    const statusEntry = await fetchMcpStatus(slug);
    if (statusEntry?.status === "connected") {
      setAlreadyConnected(true);
      setStatusChecking(false);
      await props.onComplete();
      return;
    }

    if (statusEntry?.status === "needs_client_registration") {
      setError(statusEntry.error ?? translate("mcp.auth.client_registration_required"));
    } else if (statusEntry?.status === "disabled") {
      setError(translate("mcp.auth.server_disabled"));
    } else if (statusEntry?.status === "failed") {
      setError(statusEntry.error ?? translate("mcp.auth.oauth_failed"));
    } else {
      setError(translate("mcp.auth.authorization_still_required"));
    }

    setStatusChecking(false);
  };

  const serverName = () => props.entry?.name ?? "MCP Server";

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
        {/* Backdrop */}
        <div
          class="absolute inset-0 bg-gray-1/60 backdrop-blur-sm"
          onClick={handleClose}
        />

        {/* Modal */}
        <div class="relative w-full max-w-lg bg-gray-2 border border-gray-6 rounded-2xl shadow-2xl overflow-hidden">
          {/* Header */}
          <div class="flex items-center justify-between px-6 py-4 border-b border-gray-6">
            <div>
              <h2 class="text-lg font-semibold text-gray-12">
                  {translate("mcp.auth.connect_server", { server: serverName() })}
              </h2>
              <p class="text-sm text-gray-11">{translate("mcp.auth.open_browser_signin")}</p>
            </div>
            <button
              type="button"
              class="p-2 text-gray-11 hover:text-gray-12 hover:bg-gray-4 rounded-lg transition-colors"
              onClick={handleClose}
            >
              <X size={20} />
            </button>
          </div>

          {/* Content */}
          <div class="px-6 py-5 space-y-5">
            <Show when={isBusy()}>
              <div class="flex items-center justify-center py-8">
                <Loader2 size={32} class="animate-spin text-gray-11" />
              </div>
            </Show>

            <Show when={!isBusy() && alreadyConnected()}>
              <div class="bg-green-7/10 border border-green-7/20 rounded-xl p-5 space-y-4">
                <div class="flex items-center gap-3">
                  <div class="flex-shrink-0 w-10 h-10 rounded-full bg-green-7/20 flex items-center justify-center">
                    <CheckCircle2 size={24} class="text-green-11" />
                  </div>
                  <div>
                    <p class="text-sm font-medium text-gray-12">Already Connected</p>
                    <p class="text-xs text-gray-11">
                        {translate("mcp.auth.already_connected_description", { server: serverName() })}
                    </p>
                  </div>
                </div>
                <p class="text-xs text-gray-10">
                    {translate("mcp.auth.configured_previously")}
                </p>
              </div>
            </Show>

            <Show when={reloadNotice()}>
              <div class="bg-gray-1/50 border border-gray-6/70 rounded-xl p-4 space-y-3">
                <p class="text-sm text-gray-11">{reloadNotice()}</p>

                <div class="flex flex-wrap gap-2 pt-1">
                  <Show when={props.onReloadEngine}>
                    <Button
                      variant="secondary"
                      onClick={handleReloadAndRetry}
                      disabled={props.reloadBlocked}
                      title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
                    >
                      <RefreshCcw size={14} />
                      {translate("mcp.auth.reload_engine_retry")}
                    </Button>
                  </Show>
                  <Button variant="ghost" onClick={handleRetry}>
                    {translate("mcp.auth.retry_now")}
                  </Button>
                </div>
              </div>
            </Show>

            <Show when={error()}>
              <div class="bg-red-7/10 border border-red-7/20 rounded-xl p-4 space-y-3">
                <p class="text-sm text-red-11">{error()}</p>
                
                <Show when={needsReload()}>
                  <div class="flex flex-wrap gap-2 pt-2">
                    <Show when={props.onReloadEngine}>
                      <Button
                        variant="secondary"
                        onClick={handleReloadAndRetry}
                        disabled={props.reloadBlocked}
                        title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
                      >
                        <RefreshCcw size={14} />
                        {translate("mcp.auth.reload_engine_retry")}
                      </Button>
                    </Show>
                    <Button variant="ghost" onClick={handleRetry}>
                      {translate("mcp.auth.retry_now")}
                    </Button>
                  </div>
                </Show>

                <Show when={!needsReload()}>
                  <div class="pt-2">
                    <Button variant="ghost" onClick={handleRetry}>
                      {translate("mcp.auth.retry")}
                    </Button>
                  </div>
                </Show>
              </div>
            </Show>

            <Show when={!isBusy() && authorizationUrl() && props.isRemoteWorkspace && !alreadyConnected()}>
              <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
                <div class="text-xs font-medium text-gray-12">
                  {translate("mcp.auth.manual_finish_title")}
                </div>
                <div class="text-xs text-gray-10">
                  {translate("mcp.auth.manual_finish_hint")}
                </div>
                <TextInput
                  label={translate("mcp.auth.callback_label")}
                  placeholder={translate("mcp.auth.callback_placeholder")}
                  value={callbackInput()}
                  onInput={(event) => setCallbackInput(event.currentTarget.value)}
                />
                <div class="text-[11px] text-gray-9">
                  {translate("mcp.auth.port_forward_hint")}
                </div>
                <div class="flex justify-end">
                  <Button
                    variant="secondary"
                    onClick={handleManualComplete}
                    disabled={manualAuthBusy() || !callbackInput().trim()}
                  >
                    <Show
                      when={manualAuthBusy()}
                      fallback={translate("mcp.auth.complete_connection")}
                    >
                      <Loader2 size={14} class="animate-spin" />
                      {translate("mcp.auth.complete_connection")}
                    </Show>
                  </Button>
                </div>
              </div>
            </Show>

            <Show when={!isBusy() && !error() && !reloadNotice() && !alreadyConnected()}>
              <div class="space-y-4">
                <div class="flex items-start gap-3">
                  <div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
                    1
                  </div>
                  <div>
                    <p class="text-sm font-medium text-gray-12">Opening your browser</p>
                    <p class="text-xs text-gray-10 mt-1">
                        {translate("mcp.auth.step1_description", { server: serverName() })}
                    </p>
                  </div>
                </div>

                <div class="flex items-start gap-3">
                  <div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
                    2
                  </div>
                  <div>
                    <p class="text-sm font-medium text-gray-12">Authorize OpenWork</p>
                    <p class="text-xs text-gray-10 mt-1">
                        {translate("mcp.auth.step2_description")}
                    </p>
                  </div>
                </div>

                <div class="flex items-start gap-3">
                  <div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
                    3
                  </div>
                  <div>
                    <p class="text-sm font-medium text-gray-12">Return here when you're done</p>
                    <p class="text-xs text-gray-10 mt-1">
                        {translate("mcp.auth.step3_description")}
                    </p>
                  </div>
                </div>
              </div>

              <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-11">
                  {translate("mcp.auth.waiting_authorization")}
              </div>
            </Show>
          </div>

          {/* Footer */}
          <div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-6 bg-gray-2/50">
            <Show when={alreadyConnected()}>
              <Button variant="primary" onClick={handleComplete}>
                <CheckCircle2 size={16} />
                {translate("mcp.auth.done")}
              </Button>
            </Show>
            <Show when={!alreadyConnected()}>
              <Button variant="ghost" onClick={handleClose}>
                {translate("mcp.auth.cancel")}
              </Button>
              <Button variant="secondary" onClick={handleComplete}>
                <CheckCircle2 size={16} />
                {translate("mcp.auth.im_done")}
              </Button>
            </Show>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/model-picker-modal.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";

import { CheckCircle2, Circle, Search, X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import Button from "./button";
import { modelEquals } from "../utils";
import type { ModelOption, ModelRef } from "../types";

export type ModelPickerModalProps = {
  open: boolean;
  options: ModelOption[];
  filteredOptions: ModelOption[];
  query: string;
  setQuery: (value: string) => void;
  target: "default" | "session";
  current: ModelRef;
  onSelect: (model: ModelRef) => void;
  onClose: () => void;
};

export default function ModelPickerModal(props: ModelPickerModalProps) {
  let searchInputRef: HTMLInputElement | undefined;
  const translate = (key: string) => t(key, currentLocale());

  const [activeIndex, setActiveIndex] = createSignal(0);
  const optionRefs: HTMLButtonElement[] = [];

  const activeModelIndex = createMemo(() => {
    const list = props.filteredOptions;
    return list.findIndex((opt) =>
      modelEquals(props.current, {
        providerID: opt.providerID,
        modelID: opt.modelID,
      }),
    );
  });

  const clampIndex = (next: number) => {
    const last = props.filteredOptions.length - 1;
    if (last < 0) return 0;
    return Math.max(0, Math.min(next, last));
  };

  const scrollActiveIntoView = (idx: number) => {
    const el = optionRefs[idx];
    if (!el) return;
    el.scrollIntoView({ block: "nearest" });
  };

  createEffect(() => {
    if (!props.open) return;
    requestAnimationFrame(() => {
      searchInputRef?.focus();
      if (searchInputRef?.value) {
        searchInputRef.select();
      }
    });
  });

  createEffect(() => {
    if (!props.open) return;
    const idx = activeModelIndex();
    const next = idx >= 0 ? idx : 0;
    setActiveIndex(clampIndex(next));
    requestAnimationFrame(() => scrollActiveIntoView(clampIndex(next)));
  });

  createEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (!props.open) return;
      if (event.key === "Escape") {
        event.preventDefault();
        event.stopPropagation();
        props.onClose();
        return;
      }

      if (event.key === "ArrowDown") {
        event.preventDefault();
        event.stopPropagation();
        setActiveIndex((current) => {
          const next = clampIndex(current + 1);
          requestAnimationFrame(() => scrollActiveIntoView(next));
          return next;
        });
        return;
      }

      if (event.key === "ArrowUp") {
        event.preventDefault();
        event.stopPropagation();
        setActiveIndex((current) => {
          const next = clampIndex(current - 1);
          requestAnimationFrame(() => scrollActiveIntoView(next));
          return next;
        });
        return;
      }

      if (event.key === "Enter") {
        const idx = activeIndex();
        const opt = props.filteredOptions[idx];
        if (!opt) return;
        event.preventDefault();
        event.stopPropagation();
        props.onSelect({ providerID: opt.providerID, modelID: opt.modelID });
      }
    };

    window.addEventListener("keydown", onKeyDown, true);
    return () => window.removeEventListener("keydown", onKeyDown, true);
  });

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col">
          <div class="p-6 flex flex-col min-h-0">
            <div class="flex items-start justify-between gap-4">
              <div>
                <h3 class="text-lg font-semibold text-gray-12">
                  {props.target === "default" ? translate("settings.default_model") : translate("settings.session_model")}
                </h3>
                <p class="text-sm text-gray-11 mt-1">
                  {props.target === "default" ? translate("settings.model_description_default") : translate("settings.model_description_session")}
                </p>
              </div>
              <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
                <X size={16} />
              </Button>
            </div>

            <div class="mt-5">
              <div class="relative">
                <Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-10" />
                <input
                  ref={(el) => (searchInputRef = el)}
                  type="text"
                  value={props.query}
                  onInput={(e) => props.setQuery(e.currentTarget.value)}
                  placeholder={translate("settings.search_models")}
                  class="w-full bg-gray-1/40 border border-gray-6 rounded-xl py-2.5 pl-9 pr-3 text-sm text-gray-12 placeholder-gray-6 focus:outline-none focus:ring-1 focus:ring-gray-8 focus:border-gray-8"
                />
              </div>
              <Show when={props.query.trim()}>
                <div class="mt-2 text-xs text-gray-10">
                  {translate("settings.showing_models").replace("{count}", String(props.filteredOptions.length)).replace("{total}", String(props.options.length))}
                </div>
              </Show>
            </div>

            <div class="mt-4 space-y-2 overflow-y-auto pr-1 -mr-1 min-h-0">
              <For each={props.filteredOptions}>
                {(opt, idx) => {
                  const active = () =>
                    modelEquals(props.current, {
                      providerID: opt.providerID,
                      modelID: opt.modelID,
                    });

                  const i = () => idx();

                  return (
                    <button
                      ref={(el) => {
                        optionRefs[i()] = el;
                      }}
                      class={`w-full text-left rounded-2xl border px-4 py-3 transition-colors ${
                        i() === activeIndex()
                          ? "border-gray-8 bg-gray-12/10"
                          : active()
                            ? "border-gray-6/20 bg-gray-12/5"
                            : "border-gray-6/70 bg-gray-1/40 hover:bg-gray-1/60"
                      }`}
                      onMouseEnter={() => {
                        setActiveIndex(i());
                      }}
                      onClick={() =>
                        props.onSelect({
                          providerID: opt.providerID,
                          modelID: opt.modelID,
                        })
                      }
                    >
                      <div class="flex items-start justify-between gap-3">
                        <div class="min-w-0">
                          <div class="text-sm font-medium text-gray-12 flex items-center gap-2">
                            <span class="truncate">{opt.title}</span>
                          </div>
                          <Show when={opt.description}>
                            <div class="text-xs text-gray-10 mt-1 truncate">{opt.description}</div>
                          </Show>
                          <Show when={opt.footer}>
                            <div class="text-[11px] text-gray-7 mt-2">{opt.footer}</div>
                          </Show>
                          <div class="text-[11px] text-gray-7 font-mono mt-2">
                            {opt.providerID}/{opt.modelID}
                          </div>
                        </div>

                        <div class="pt-0.5 text-gray-10">
                          <Show when={active()} fallback={<Circle size={14} />}>
                            <CheckCircle2 size={14} class="text-green-11" />
                          </Show>
                        </div>
                      </div>
                    </button>
                  );
                }}
              </For>
            </div>

            <div class="mt-5 flex justify-end shrink-0">
              <Button variant="outline" onClick={props.onClose}>
                {translate("settings.done")}
              </Button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/onboarding-workspace-selector.tsx
================================================
import { For, Show, createEffect, createSignal } from "solid-js";

import { CheckCircle2, FolderPlus, Loader2 } from "lucide-solid";

import Button from "./button";

export default function OnboardingWorkspaceSelector(props: {
  defaultPath: string;
  onConfirm: (preset: "starter" | "automation" | "minimal", folder: string | null) => void;
  onPickFolder: () => Promise<string | null>;
}) {
  const [preset, setPreset] = createSignal<"starter" | "automation" | "minimal">("starter");
  const [selectedFolder, setSelectedFolder] = createSignal(props.defaultPath);
  const [pickingFolder, setPickingFolder] = createSignal(false);

  const options = () => [
    {
      id: "starter" as const,
      name: "Starter workspace",
      desc: "Preconfigured to show you how to use plugins, commands, and skills.",
    },
    {
      id: "minimal" as const,
      name: "Empty workspace",
      desc: "Start with a blank folder and add what you need.",
    },
  ];

  const canContinue = () => Boolean(selectedFolder().trim());

  createEffect(() => {
    if (!selectedFolder().trim()) {
      setSelectedFolder(props.defaultPath);
    }
  });

  const handlePickFolder = async () => {
    if (pickingFolder()) return;
    setPickingFolder(true);
    try {
      await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
      const next = await props.onPickFolder();
      if (next) {
        setSelectedFolder(next);
      }
    } finally {
      setPickingFolder(false);
    }
  };

  return (
    <div class="bg-gray-2 border border-gray-6 rounded-2xl overflow-hidden flex flex-col max-h-[90vh]">
      <div class="p-6 flex-1 overflow-y-auto space-y-8">
        <div class="space-y-4">
          <div class="flex items-center gap-3 text-sm font-medium text-gray-12">
            <div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">1</div>
            Select Folder
          </div>
          <div class="ml-9">
            <div
              class={`w-full border border-dashed border-gray-6 bg-gray-1/40 rounded-xl p-4 text-left transition ${
                pickingFolder() ? "opacity-70" : "hover:border-zinc-500"
              }`.trim()}
            >
              <div class="flex items-center gap-3 text-gray-10">
                <FolderPlus size={20} class="text-gray-6" />
                <input
                  class="flex-1 min-w-0 bg-transparent text-sm font-medium text-gray-10 placeholder-gray-600 focus:outline-none"
                  value={selectedFolder()}
                  onInput={(e) => setSelectedFolder(e.currentTarget.value)}
                  placeholder={props.defaultPath}
                />
                <button
                  type="button"
                  onClick={handlePickFolder}
                  disabled={pickingFolder()}
                  class="text-xs text-gray-6 hover:text-gray-10 transition-colors"
                >
                  <Show
                    when={pickingFolder()}
                    fallback={<span>Choose</span>}
                  >
                    <span class="inline-flex items-center gap-2">
                      <Loader2 size={12} class="animate-spin" />
                      Opening...
                    </span>
                  </Show>
                </button>
              </div>
            </div>
          </div>
        </div>

        <div class="space-y-4">
          <div class="flex items-center gap-3 text-sm font-medium text-gray-12">
            <div class="w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs">2</div>
            Choose Preset
          </div>
          <div class={`ml-9 grid gap-3 ${!canContinue() ? "opacity-50" : ""}`.trim()}>
            <For each={options()}>
              {(opt) => (
                <div
                  onClick={() => {
                    if (!canContinue()) return;
                    setPreset(opt.id);
                  }}
                  class={`p-4 rounded-xl border cursor-pointer transition-all ${
                    preset() === opt.id
                      ? "bg-gray-4 border-gray-6 hover:border-gray-7"
                      : "bg-gray-1/40 border-gray-6 hover:border-gray-7"
                  } ${!canContinue() ? "pointer-events-none" : ""}`.trim()}
                >
                  <div class="flex justify-between items-start">
                    <div>
                      <div
                        class={`font-medium text-sm ${
                          preset() === opt.id ? "text-indigo-400" : "text-zinc-200"
                        }`}
                      >
                        {opt.name}
                      </div>
                      <div class="text-xs text-zinc-500 mt-1">{opt.desc}</div>
                    </div>
                    <Show when={preset() === opt.id}>
                      <CheckCircle2 size={16} class="text-indigo-500" />
                    </Show>
                  </div>
                </div>
              )}
            </For>
          </div>
        </div>
      </div>

    </div>
  );
}



================================================
FILE: packages/app/src/app/components/openwork-logo.tsx
================================================
import type { JSX } from "solid-js";

type Props = {
  size?: number;
  class?: string;
};

export default function OpenWorkLogo(props: Props): JSX.Element {
  const size = props.size ?? 24;
  return (
    <img
      src="/openwork-logo.svg"
      alt="OpenWork"
      width={size}
      height={size}
      class={`inline-block ${props.class ?? ""}`}
    />
  );
}



================================================
FILE: packages/app/src/app/components/part-view.tsx
================================================
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { marked } from "marked";
import type { Part } from "@opencode-ai/sdk/v2/client";
import { safeStringify } from "../utils";

type Props = {
  part: Part;
  developerMode?: boolean;
  showThinking?: boolean;
  tone?: "light" | "dark";
  renderMarkdown?: boolean;
};

function clampText(text: string, max = 800) {
  if (text.length <= max) return text;
  return `${text.slice(0, max)}\n\n… (truncated)`;
}

function useThrottledValue<T>(value: () => T, delayMs = 80) {
  const [state, setState] = createSignal<T>(value());
  let timer: ReturnType<typeof setTimeout> | undefined;

  createEffect(() => {
    const next = value();
    if (!delayMs) {
      setState(() => next);
      return;
    }
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      setState(() => next);
      timer = undefined;
    }, delayMs);
  });

  onCleanup(() => {
    if (timer) clearTimeout(timer);
  });

  return state;
}

function createCustomRenderer(tone: "light" | "dark") {
  const renderer = new marked.Renderer();
  const codeBlockClass =
    tone === "dark"
      ? "bg-gray-12/10 border-gray-11/20 text-gray-12"
      : "bg-gray-1/80 border-gray-6/70 text-gray-12";
  const inlineCodeClass =
    tone === "dark"
      ? "bg-gray-12/15 text-gray-12"
      : "bg-gray-2/70 text-gray-12";
  
  const escapeHtml = (s: string) =>
    s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

  const isSafeUrl = (url: string) => {
    const protocol = (url || "").trim().toLowerCase();
    return !protocol.startsWith("javascript:") && !protocol.startsWith("data:");
  };

  renderer.html = ({ text }) => escapeHtml(text);

  renderer.code = ({ text, lang }) => {
    const language = lang || "";
    return `
      <div class="rounded-2xl border px-4 py-3 my-4 ${codeBlockClass}">
        ${
          language
            ? `<div class="text-[10px] uppercase tracking-[0.2em] text-gray-9 mb-2">${escapeHtml(language)}</div>`
            : ""
        }
        <pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono"><code>${escapeHtml(
          text
        )}</code></pre>
      </div>
    `;
  };

  renderer.codespan = ({ text }) => {
    return `<code class="rounded-md px-1.5 py-0.5 text-[13px] font-mono ${inlineCodeClass}">${escapeHtml(
      text
    )}</code>`;
  };

  renderer.link = ({ href, title, text }) => {
    const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "#") : "#";
    const safeTitle = title ? escapeHtml(title) : "";
    return `
      <a
        href="${safeHref}"
        target="_blank"
        rel="noopener noreferrer"
        class="underline underline-offset-2 text-blue-600 hover:text-blue-700"
        ${safeTitle ? `title="${safeTitle}"` : ""}
      >
        ${text}
      </a>
    `;
  };

  renderer.image = ({ href, title, text }) => {
    const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "") : "";
    const safeTitle = title ? escapeHtml(title) : "";
    return `
      <img
        src="${safeHref}"
        alt="${escapeHtml(text || "")}"
        ${safeTitle ? `title="${safeTitle}"` : ""}
        class="max-w-full h-auto rounded-lg my-4"
      />
    `;
  };

  return renderer;
}

export default function PartView(props: Props) {
  const p = () => props.part;
  const developerMode = () => props.developerMode ?? false;
  const tone = () => props.tone ?? "light";
  const showThinking = () => props.showThinking ?? true;
  const renderMarkdown = () => props.renderMarkdown ?? false;

  const textClass = () => (tone() === "dark" ? "text-gray-12" : "text-gray-12");
  const subtleTextClass = () => (tone() === "dark" ? "text-gray-12/70" : "text-gray-11");
  const panelBgClass = () => (tone() === "dark" ? "bg-gray-2/10" : "bg-gray-2/30");
  const toolOnly = () => true;
  const showToolOutput = () => developerMode();
  const markdownSource = createMemo(() => {
    if (!renderMarkdown() || p().type !== "text") return "";
    return "text" in p() ? String((p() as { text: string }).text ?? "") : "";
  });
  const throttledMarkdownSource = useThrottledValue(markdownSource, 100);
  const renderedMarkdown = createMemo(() => {
    if (!renderMarkdown() || p().type !== "text") return null;
    const text = throttledMarkdownSource();
    if (!text.trim()) return "";
    
    try {
      const renderer = createCustomRenderer(tone());
      const result = marked.parse(text, { 
        breaks: true, 
        gfm: true,
        renderer,
        async: false
      });
      
      return typeof result === 'string' ? result : '';
    } catch (error) {
      console.error('Markdown parsing error:', error);
      return null;
    }
  });

  const toolData = () => {
    if (p().type !== "tool") return null;
    return p() as any;
  };

  const toolState = () => toolData()?.state ?? {};
  const toolName = () => (toolData()?.tool ? String(toolData()?.tool) : "tool");
  const toolTitle = () => (toolState()?.title ? String(toolState().title) : toolName());
  const toolStatus = () => (toolState()?.status ? String(toolState().status) : "unknown");
  const toolSubtitle = () =>
    toolState()?.subtitle || toolState()?.detail || toolState()?.summary
      ? String(toolState().subtitle ?? toolState().detail ?? toolState().summary)
      : "";

  const extractDiff = () => {
    const state = toolState();
    const candidates = [state?.diff, state?.patch, state?.output];
    for (const candidate of candidates) {
      if (typeof candidate !== "string") continue;
      if (candidate.includes("@@") || candidate.includes("+++ ") || candidate.includes("--- ")) {
        return candidate;
      }
    }
    return null;
  };

  const diffText = createMemo(() => (p().type === "tool" ? extractDiff() : null));
  const diffLines = createMemo(() => (diffText() ? diffText()!.split("\n") : []));
  const diffLineClass = (line: string) => {
    if (line.startsWith("+")) return "text-green-11 bg-green-1/40";
    if (line.startsWith("-")) return "text-red-11 bg-red-1/40";
    if (line.startsWith("@@")) return "text-blue-11 bg-blue-1/30";
    return "text-gray-12";
  };

  const toolOutput = () => {
    const output = toolState()?.output;
    return typeof output === "string" ? output : null;
  };

  const toolError = () => {
    const error = toolState()?.error;
    return typeof error === "string" ? error : null;
  };

  const toolInput = () => toolState()?.input;

  const diagnostics = () => {
    const items = toolState()?.diagnostics;
    return Array.isArray(items) ? items : [];
  };

  const formatDiagnosticLocation = (diagnostic: any) => {
    const raw = diagnostic?.file ?? diagnostic?.path ?? diagnostic?.uri ?? "";
    const file = typeof raw === "string" ? raw.replace(/^file:\/\//, "") : "";
    const line = diagnostic?.line ?? diagnostic?.range?.start?.line;
    const character = diagnostic?.character ?? diagnostic?.range?.start?.character;
    const location =
      typeof line === "number"
        ? `${line + 1}${typeof character === "number" ? `:${character + 1}` : ""}`
        : "";
    return `${file}${file && location ? ":" : ""}${location}`.trim();
  };

  const formatDiagnosticLabel = (diagnostic: any) => {
    const severity = diagnostic?.severity ?? diagnostic?.level;
    if (typeof severity === "string") return severity;
    if (severity === 1) return "error";
    if (severity === 2) return "warning";
    if (severity === 3) return "info";
    if (severity === 4) return "hint";
    return "diagnostic";
  };

  const isLargeOutput = createMemo(() => {
    const output = toolOutput();
    return !!output && output.length > 800;
  });

  const [expandedOutput, setExpandedOutput] = createSignal(false);
  const outputPreview = createMemo(() => {
    const output = toolOutput();
    if (!output) return "";
    if (isLargeOutput() && !expandedOutput()) {
      return `${output.slice(0, 800)}\n\n… (truncated)`;
    }
    return output;
  });

  const toolImages = () => {
    const state = toolState();
    const candidates = Array.isArray(state?.images) ? state.images : [];
    return candidates
      .map((item: any) => {
        if (typeof item === "string") return { src: item, alt: "" };
        const src = item?.url ?? item?.src ?? item?.data;
        if (!src) return null;
        if (item?.data && item?.mediaType && !String(item.data).startsWith("data:")) {
          return { src: `data:${item.mediaType};base64,${item.data}`, alt: item?.alt ?? "" };
        }
        return { src, alt: item?.alt ?? "" };
      })
      .filter(Boolean);
  };

  const inlineImage = () => {
    if (p().type !== "file") return null;
    const record = p() as any;
    const mime = typeof record?.mime === "string" ? record.mime : "";
    if (!mime.startsWith("image/")) return null;
    const src = record?.url ?? record?.src ?? record?.data ?? record?.source;
    if (!src) return null;
    if (record?.data && record?.mediaType && !String(record.data).startsWith("data:")) {
      return `data:${record.mediaType};base64,${record.data}`;
    }
    return src as string;
  };

  return (
    <Switch>
      <Match when={p().type === "text"}>
        <Show
          when={renderMarkdown()}
          fallback={
            <div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>
              {"text" in p() ? (p() as { text: string }).text : ""}
            </div>
          }
        >
          <Show
            when={renderedMarkdown()}
            fallback={
              <div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>
                {"text" in p() ? (p() as { text: string }).text : ""}
              </div>
            }
          >
            <div
              class={`markdown-content max-w-none ${textClass()}
                [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-4
                [&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3
                [&_h3]:text-lg [&_h3]:font-bold [&_h3]:my-2
                [&_p]:my-3 [&_p]:leading-relaxed
                [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3
                [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3
                [&_li]:my-1
                [&_blockquote]:border-l-4 [&_blockquote]:border-gray-300 [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic
                [&_table]:w-full [&_table]:border-collapse [&_table]:my-4
                [&_th]:border [&_th]:border-gray-300 [&_th]:p-2 [&_th]:bg-gray-50
                [&_td]:border [&_td]:border-gray-300 [&_td]:p-2
              `.trim()}
              innerHTML={renderedMarkdown()!}
            />
          </Show>
        </Show>
      </Match>

      <Match when={p().type === "reasoning"}>
        <Show
          when={
            showThinking() &&
            developerMode() &&
            "text" in p() &&
            typeof (p() as { text: string }).text === "string" &&
            (p() as { text: string }).text.trim()
          }
        >
          <details class={`rounded-lg ${panelBgClass()} p-2`.trim()}>
            <summary class={`cursor-pointer text-xs ${subtleTextClass()}`.trim()}>Thinking</summary>
            <pre
              class={`mt-2 whitespace-pre-wrap break-words text-xs ${
                tone() === "dark" ? "text-gray-1" : "text-gray-12"
              }`.trim()}
            >
              {clampText(String((p() as { text: string }).text), 2000)}
            </pre>
          </details>
        </Show>
      </Match>

      <Match when={p().type === "tool"}>
        <Show when={toolOnly()}>
          <div class="grid gap-3">
            <div class="flex items-start justify-between gap-3">
              <div class="space-y-1">
                <div
                  class={`text-xs font-medium ${tone() === "dark" ? "text-gray-1" : "text-gray-12"}`.trim()}
                >
                  {toolTitle()}
                </div>
                <div class={`text-[11px] ${subtleTextClass()}`.trim()}>{toolName()}</div>
              </div>
              <div
                class={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
                  toolStatus() === "completed"
                    ? "bg-green-3/15 text-green-12"
                    : toolStatus() === "running"
                      ? "bg-blue-3/15 text-blue-12"
                      : toolStatus() === "error"
                        ? "bg-red-3/15 text-red-12"
                        : "bg-gray-2/10 text-gray-1"
                }`}
              >
                {toolStatus()}
              </div>
            </div>

            <Show when={toolSubtitle()}>
              <div class={`text-xs ${subtleTextClass()}`.trim()}>{toolSubtitle()}</div>
            </Show>

            <Show when={diagnostics().length > 0}>
              <div class={`rounded-lg border ${panelBgClass()} p-2`.trim()}>
                <div class={`text-[11px] font-medium ${subtleTextClass()}`.trim()}>Diagnostics</div>
                <div class="mt-2 grid gap-2">
                  <For each={diagnostics()}>
                    {(diag: any) => (
                      <div class="flex items-start justify-between gap-4 text-xs">
                        <div>
                          <div class="font-medium text-gray-12">{String(diag?.message ?? "")}</div>
                          <Show when={diag?.source || diag?.code}>
                            <div class="text-[11px] text-gray-10">
                              {[diag?.source, diag?.code].filter(Boolean).join(" · ")}
                            </div>
                          </Show>
                        </div>
                        <div class="text-[11px] text-gray-10 text-right">
                          <div>{formatDiagnosticLabel(diag)}</div>
                          <Show when={formatDiagnosticLocation(diag)}>
                            <div>{formatDiagnosticLocation(diag)}</div>
                          </Show>
                        </div>
                      </div>
                    )}
                  </For>
                </div>
              </div>
            </Show>

            <Show when={diffText()}>
              <div class={`rounded-lg border ${panelBgClass()} p-2`.trim()}>
                <div class={`text-[11px] font-medium ${subtleTextClass()}`.trim()}>Diff</div>
                <div class="mt-2 grid gap-1 rounded-md overflow-hidden">
                  <For each={diffLines()}>
                    {(line) => (
                      <div
                        class={`font-mono text-[11px] leading-relaxed px-2 py-0.5 whitespace-pre-wrap break-words ${diffLineClass(
                          line,
                        )}`.trim()}
                      >
                        {line || " "}
                      </div>
                    )}
                  </For>
                </div>
              </div>
            </Show>

            <Show when={toolImages().length > 0}>
              <div class="grid gap-2">
                <For each={toolImages()}>
                  {(image: any) => (
                    <img
                      src={image.src}
                      alt={image.alt || ""}
                      class="max-w-full h-auto rounded-lg border border-gray-6/50"
                    />
                  )}
                </For>
              </div>
            </Show>

            <Show when={toolError()}>
              <div class="rounded-lg bg-red-1/40 p-2 text-xs text-red-12">
                {toolError()}
              </div>
            </Show>

            <Show when={showToolOutput() && toolOutput() && toolOutput() !== diffText()}>
              <pre
                class={`whitespace-pre-wrap break-words rounded-lg ${panelBgClass()} p-2 text-xs ${
                  tone() === "dark" ? "text-gray-12" : "text-gray-1"
                }`.trim()}
              >
                {outputPreview()}
              </pre>
            </Show>

            <Show when={showToolOutput() && isLargeOutput()}>
              <button
                class={`text-[11px] ${subtleTextClass()} hover:text-gray-12 transition-colors`}
                onClick={() => setExpandedOutput((current) => !current)}
              >
                {expandedOutput() ? "Show less" : "Show more"}
              </button>
            </Show>

            <Show when={showToolOutput() && toolInput() != null}>
              <details class={`rounded-lg ${panelBgClass()} p-2`.trim()}>
                <summary class={`cursor-pointer text-xs ${subtleTextClass()}`.trim()}>Input</summary>
                <pre
                  class={`mt-2 whitespace-pre-wrap break-words text-xs ${
                    tone() === "dark" ? "text-gray-12" : "text-gray-1"
                  }`.trim()}
                >
                  {safeStringify(toolInput())}
                </pre>
              </details>
            </Show>
          </div>
        </Show>
      </Match>

      <Match when={inlineImage()}>
        <img
          src={inlineImage()!}
          alt=""
          class="max-w-full h-auto rounded-xl border border-gray-6/50"
        />
      </Match>

      <Match when={p().type === "step-start" || p().type === "step-finish"}>
        <div class={`text-xs ${subtleTextClass()}`.trim()}>
          {p().type === "step-start" ? "Step started" : "Step finished"}
          <Show when={"reason" in p() && (p() as any).reason}>
            <span class={tone() === "dark" ? "text-gray-12/80" : "text-gray-11"}>
              {" "}· {String((p() as any).reason)}
            </span>
          </Show>
        </div>
      </Match>

      <Match when={true}>
        <Show when={developerMode()}>
          <pre
            class={`whitespace-pre-wrap break-words text-xs ${
              tone() === "dark" ? "text-gray-12" : "text-gray-1"
            }`.trim()}
          >
            {safeStringify(p())}
          </pre>
        </Show>
      </Match>
    </Switch>
  );
}



================================================
FILE: packages/app/src/app/components/provider-auth-modal.tsx
================================================
import { CheckCircle2, X } from "lucide-solid";
import type { ProviderListItem } from "../types";
import { createMemo, For, Show } from "solid-js";

import Button from "./button";

type ProviderAuthMethod = { type: "oauth" | "api"; label: string };
type ProviderAuthEntry = {
  id: string;
  name: string;
  methods: ProviderAuthMethod[];
  connected: boolean;
};

export type ProviderAuthModalProps = {
  open: boolean;
  loading: boolean;
  submitting: boolean;
  error: string | null;
  providers: ProviderListItem[];
  connectedProviderIds: string[];
  authMethods: Record<string, ProviderAuthMethod[]>;
  onSelect: (providerId: string) => void;
  onClose: () => void;
};

export default function ProviderAuthModal(props: ProviderAuthModalProps) {
  const entries = createMemo<ProviderAuthEntry[]>(() => {
    const methods = props.authMethods ?? {};
    const connected = new Set(props.connectedProviderIds ?? []);
    const providers = props.providers ?? [];

    return Object.keys(methods)
      .map((id): ProviderAuthEntry => {
        const provider = providers.find((item) => item.id === id);
        return {
          id,
          name: provider?.name ?? id,
          methods: methods[id] ?? [],
          connected: connected.has(id),
        };
      })
      .sort((a, b) => {
        const aIsOpencode = a.id === "opencode";
        const bIsOpencode = b.id === "opencode";
        if (aIsOpencode !== bIsOpencode) return aIsOpencode ? -1 : 1;
        return a.name.localeCompare(b.name);
      });
  });

  const methodLabel = (method: ProviderAuthMethod) =>
    method.label || (method.type === "oauth" ? "OAuth" : "API key");

  const actionDisabled = () => props.loading || props.submitting;

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col">
          <div class="p-6 flex flex-col min-h-0">
            <div class="flex items-start justify-between gap-4">
              <div>
                <h3 class="text-lg font-semibold text-gray-12">Connect provider</h3>
                <p class="text-sm text-gray-11 mt-1">Choose a provider to authenticate.</p>
              </div>
              <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
                <X size={16} />
              </Button>
            </div>

            <Show when={props.error}>
              <div class="mt-4 rounded-xl border border-red-7/30 bg-red-1/40 px-3 py-2 text-xs text-red-11">
                {props.error}
              </div>
            </Show>

            <Show when={props.loading}>
              <div class="mt-6 rounded-xl border border-gray-6 bg-gray-1/60 px-4 py-3 text-sm text-gray-10 animate-pulse">
                Loading providers...
              </div>
            </Show>

            <Show when={!props.loading}>
              <div class="mt-5 space-y-2 overflow-y-auto pr-1 -mr-1 min-h-0">
                <Show
                  when={entries().length}
                  fallback={<div class="text-sm text-gray-10">No providers available.</div>}
                >
                  <For each={entries()}>
                    {(entry) => (
                      <button
                        type="button"
                        class="w-full rounded-xl border border-gray-6 bg-gray-1/40 px-4 py-3 text-left transition-colors hover:bg-gray-1/70 disabled:opacity-60 disabled:cursor-not-allowed"
                        disabled={actionDisabled()}
                        onClick={() => props.onSelect(entry.id)}
                      >
                        <div class="flex items-center justify-between gap-3">
                          <div class="min-w-0">
                            <div class="text-sm font-medium text-gray-12 truncate">{entry.name}</div>
                            <div class="text-[11px] text-gray-8 font-mono truncate">{entry.id}</div>
                          </div>
                          <Show when={entry.connected}>
                            <div class="flex items-center gap-1 text-[11px] text-green-11 bg-green-7/10 border border-green-7/20 px-2 py-1 rounded-full">
                              <CheckCircle2 size={12} />
                              Connected
                            </div>
                          </Show>
                        </div>
                        <div class="mt-2 flex flex-wrap gap-2">
                          <For each={entry.methods}>
                            {(method) => (
                              <span
                                class={`text-[10px] uppercase tracking-[0.2em] px-2 py-1 rounded-full border ${
                                  method.type === "oauth"
                                    ? "bg-indigo-7/15 text-indigo-11 border-indigo-7/30"
                                    : "bg-gray-3 text-gray-11 border-gray-6"
                                }`}
                              >
                                {methodLabel(method)}
                              </span>
                            )}
                          </For>
                        </div>
                      </button>
                    )}
                  </For>
                </Show>
              </div>
            </Show>

            <Show when={props.submitting}>
              <div class="mt-4 text-xs text-gray-10">Opening authentication...</div>
            </Show>

            <div class="mt-4 text-xs text-gray-9">
              OAuth providers open in your browser. API key providers require editing your `opencode.json`.
            </div>

            <Button variant="ghost" class="mt-4" onClick={props.onClose} disabled={actionDisabled()}>
              Close
            </Button>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/reload-workspace-toast.tsx
================================================
import { Show } from "solid-js";
import { AlertTriangle, RefreshCcw, X } from "lucide-solid";

import Button from "./button";

export type ReloadWorkspaceToastProps = {
  open: boolean;
  title: string;
  description: string;
  warning?: string;
  blockedReason?: string | null;
  error?: string | null;
  reloadLabel: string;
  dismissLabel: string;
  busy?: boolean;
  canReload: boolean;
  hasActiveRuns: boolean;
  onReload: () => void;
  onDismiss: () => void;
};

export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) {
  return (
    <Show when={props.open}>
      <div class="fixed top-6 left-1/2 -translate-x-1/2 z-50 w-[min(480px,calc(100vw-2rem))]">
        <div 
          class="
            flex items-center gap-3 p-2 pr-3 rounded-full 
            border border-gray-6/50 bg-gray-2/95 shadow-xl backdrop-blur-md 
            animate-in fade-in slide-in-from-top-4 duration-300
          "
        >
          {/* Icon Circle */}
          <div class={`
            flex h-9 w-9 shrink-0 items-center justify-center rounded-full 
            ${props.hasActiveRuns ? 'bg-amber-3 text-amber-11' : 'bg-blue-3 text-blue-11'}
          `}>
            <RefreshCcw size={16} class={props.busy ? "animate-spin" : ""} />
          </div>

          {/* Text Content */}
          <div class="flex-1 min-w-0 flex flex-col justify-center">
            <div class="flex items-center gap-2">
              <span class="text-sm font-medium text-gray-12 truncate">
                {props.title}
              </span>
              <Show when={props.hasActiveRuns}>
                <span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider bg-amber-4 text-amber-11">
                  Active Tasks
                </span>
              </Show>
            </div>
            
            <Show when={props.description || props.error}>
              <div class="text-xs text-gray-10 truncate leading-none mt-0.5">
                {props.hasActiveRuns 
                  ? <span class="text-amber-11 font-medium">Reloading will stop active tasks.</span>
                  : props.error 
                  ? <span class="text-red-9 font-medium">{props.error}</span>
                  : props.description
                }
              </div>
            </Show>
          </div>

          {/* Actions */}
          <div class="flex items-center gap-2 shrink-0 pl-2 border-l border-gray-5/50">
             <button 
              onClick={() => props.onDismiss()}
              class="px-2 py-1.5 text-xs font-medium text-gray-10 hover:text-gray-12 transition-colors"
            >
              {props.dismissLabel}
            </button>
            <Button
              variant={props.hasActiveRuns ? "danger" : "primary"}
              class="h-7 px-3 text-xs rounded-full font-medium"
              onClick={() => props.onReload()}
              disabled={props.busy || !props.canReload}
            >
              {props.reloadLabel}
            </Button>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/rename-session-modal.tsx
================================================
import { Show, createEffect } from "solid-js";
import { X } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import Button from "./button";
import TextInput from "./text-input";

export type RenameSessionModalProps = {
  open: boolean;
  title: string;
  busy: boolean;
  canSave: boolean;
  onClose: () => void;
  onSave: () => void;
  onTitleChange: (value: string) => void;
};

export default function RenameSessionModal(props: RenameSessionModalProps) {
  let inputRef: HTMLInputElement | undefined;
  const translate = (key: string) => t(key, currentLocale());

  createEffect(() => {
    if (props.open) {
      requestAnimationFrame(() => {
        inputRef?.focus();
        if (inputRef) {
          inputRef.select();
        }
      });
    }
  });

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
          <div class="p-6">
            <div class="flex items-start justify-between gap-4">
              <div>
                <h3 class="text-lg font-semibold text-gray-12">{translate("session.rename_title")}</h3>
                <p class="text-sm text-gray-11 mt-1">{translate("session.rename_description")}</p>
              </div>
              <Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
                <X size={16} />
              </Button>
            </div>

            <div class="mt-6">
              <TextInput
                ref={inputRef}
                label={translate("session.rename_label")}
                value={props.title}
                onInput={(e) => props.onTitleChange(e.currentTarget.value)}
                placeholder={translate("session.rename_placeholder")}
                class="bg-gray-3"
                onKeyDown={(event) => {
                  if (event.key !== "Enter") return;
                  event.preventDefault();
                  if (props.canSave) props.onSave();
                }}
              />
            </div>

            <div class="mt-6 flex justify-end gap-2">
              <Button variant="outline" onClick={props.onClose} disabled={props.busy}>
                {translate("common.cancel")}
              </Button>
              <Button onClick={props.onSave} disabled={!props.canSave}>
                {translate("common.save")}
              </Button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/reset-modal.tsx
================================================
import { Match, Show, Switch } from "solid-js";

import { X } from "lucide-solid";
import { t, currentLocale, type Language } from "../../i18n";

import Button from "./button";
import TextInput from "./text-input";

export type ResetModalProps = {
  open: boolean;
  mode: "onboarding" | "all";
  text: string;
  busy: boolean;
  canReset: boolean;
  hasActiveRuns: boolean;
  language: Language;
  onClose: () => void;
  onConfirm: () => void;
  onTextChange: (value: string) => void;
};

export default function ResetModal(props: ResetModalProps) {
  const translate = (key: string) => t(key, props.language);

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
        <div class="bg-gray-2 border border-gray-6/70 w-full max-w-xl rounded-2xl shadow-2xl overflow-hidden">
          <div class="p-6">
            <div class="flex items-start justify-between gap-4">
              <div>
                <h3 class="text-lg font-semibold text-gray-12">
                  <Switch>
                    <Match when={props.mode === "onboarding"}>{translate("settings.reset_onboarding_title")}</Match>
                    <Match when={true}>{translate("settings.reset_app_data_title")}</Match>
                  </Switch>
                </h3>
                <p class="text-sm text-gray-11 mt-1" innerHTML={translate("settings.reset_confirmation_hint")} />
              </div>
              <Button
                variant="ghost"
                class="!p-2 rounded-full"
                onClick={props.onClose}
                disabled={props.busy}
              >
                <X size={16} />
              </Button>
            </div>

            <div class="mt-6 space-y-4">
              <div class="rounded-xl bg-gray-1/20 border border-gray-6 p-3 text-xs text-gray-11">
                <Switch>
                  <Match when={props.mode === "onboarding"}>
                    {translate("settings.reset_onboarding_warning")}
                  </Match>
                  <Match when={true}>{translate("settings.reset_app_data_warning")}</Match>
                </Switch>
              </div>

              <Show when={props.hasActiveRuns}>
                <div class="text-xs text-red-11">{translate("settings.reset_stop_active_runs")}</div>
              </Show>

              <TextInput
                label={translate("settings.reset_confirmation_label")}
                placeholder={translate("settings.reset_confirmation_placeholder")}
                value={props.text}
                onInput={(e) => props.onTextChange(e.currentTarget.value)}
                disabled={props.busy}
              />
            </div>

            <div class="mt-6 flex justify-end gap-2">
              <Button variant="outline" onClick={props.onClose} disabled={props.busy}>
                {translate("settings.reset_cancel")}
              </Button>
              <Button variant="danger" onClick={props.onConfirm} disabled={!props.canReset}>
                {translate("settings.reset_confirm_button")}
              </Button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/settings-keybinds.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";

import Button from "./button";
import { formatKeybindLabel, keybindFromEvent } from "../utils/keybinds";

export type KeybindSetting = {
  id: string;
  title: string;
  category?: string;
  description?: string;
  defaultKeybind?: string | null;
  overrideKeybind?: string | null;
  conflicts?: string[];
};

export type SettingsKeybindsProps = {
  items: KeybindSetting[];
  onOverride: (id: string, keybind: string | null) => void;
  onReset: (id: string) => void;
  onResetAll: () => void;
};

export default function SettingsKeybinds(props: SettingsKeybindsProps) {
  const [capturingId, setCapturingId] = createSignal<string | null>(null);

  const grouped = createMemo(() => {
    const groups = new Map<string, KeybindSetting[]>();
    for (const item of props.items) {
      const category = item.category ?? "General";
      const list = groups.get(category) ?? [];
      list.push(item);
      groups.set(category, list);
    }
    return Array.from(groups.entries()).map(([title, items]) => ({
      title,
      items: items.sort((a, b) => a.title.localeCompare(b.title)),
    }));
  });

  createEffect(() => {
    if (!capturingId()) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (!capturingId()) return;
      if (event.key === "Escape") {
        event.preventDefault();
        event.stopPropagation();
        setCapturingId(null);
        return;
      }
      const keybind = keybindFromEvent(event);
      if (!keybind) return;
      event.preventDefault();
      event.stopPropagation();
      props.onOverride(capturingId()!, keybind);
      setCapturingId(null);
    };
    window.addEventListener("keydown", onKeyDown, true);
    onCleanup(() => window.removeEventListener("keydown", onKeyDown, true));
  });

  const hasOverrides = createMemo(() =>
    props.items.some((item) => item.overrideKeybind && item.overrideKeybind !== item.defaultKeybind),
  );

  return (
    <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
      <div class="flex items-center justify-between">
        <div>
          <div class="text-sm font-medium text-gray-12">Keybinds</div>
          <div class="text-xs text-gray-10">Customize global and session shortcuts.</div>
        </div>
        <Button variant="outline" disabled={!hasOverrides()} onClick={props.onResetAll}>
          Reset all
        </Button>
      </div>

      <div class="space-y-6">
        <For each={grouped()}>
          {(group: { title: string; items: KeybindSetting[] }) => (
            <div class="space-y-3">
              <div class="text-xs font-semibold uppercase tracking-wider text-gray-9">{group.title}</div>
              <div class="space-y-2">
                <For each={group.items}>
                  {(item: KeybindSetting) => {
                    const activeKeybind = () => item.overrideKeybind ?? item.defaultKeybind ?? null;
                    const conflictText = () =>
                      item.conflicts?.length
                        ? `Conflicts with ${item.conflicts.join(", ")}`
                        : null;
                    return (
                      <div class="bg-gray-1 border border-gray-6 rounded-xl px-4 py-3 flex items-center justify-between gap-4">
                        <div class="min-w-0">
                          <div class="text-sm text-gray-12">{item.title}</div>
                          <Show when={item.description}>
                            <div class="text-xs text-gray-8 mt-1">{item.description}</div>
                          </Show>
                          <Show when={conflictText()}>
                            <div class="text-xs text-amber-11 mt-1">{conflictText()}</div>
                          </Show>
                        </div>
                        <div class="flex items-center gap-2 shrink-0">
                          <div class="text-xs text-gray-8 font-mono px-2 py-1 border border-gray-6 rounded-lg">
                            {activeKeybind() ? formatKeybindLabel(activeKeybind()!) : "Unassigned"}
                          </div>
                          <Button
                            variant={capturingId() === item.id ? "secondary" : "outline"}
                            class="text-xs h-8 px-3"
                            onClick={() =>
                              setCapturingId(capturingId() === item.id ? null : item.id)
                            }
                          >
                            {capturingId() === item.id ? "Press keys" : "Record"}
                          </Button>
                          <Button
                            variant="ghost"
                            class="text-xs h-8 px-3"
                            disabled={!item.overrideKeybind}
                            onClick={() => props.onReset(item.id)}
                          >
                            Reset
                          </Button>
                        </div>
                      </div>
                    );
                  }}
                </For>
              </div>
            </div>
          )}
        </For>
      </div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/status-bar.tsx
================================================
import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import { Cpu, MessageCircle, Server, Settings } from "lucide-solid";

import type { OpenworkServerStatus } from "../lib/openwork-server";
import type { OwpenbotStatus } from "../lib/tauri";
import { getOwpenbotStatus } from "../lib/tauri";

import Button from "./button";

type StatusBarProps = {
  mode: "host" | "client" | null;
  busy: boolean;
  clientConnected: boolean;
  openworkServerStatus: OpenworkServerStatus;
  developerMode: boolean;
  stopHost: () => void;
  onOpenSettings: () => void;
  onOpenMessaging: () => void;
};

export default function StatusBar(props: StatusBarProps) {
  const [owpenbotStatus, setOwpenbotStatus] = createSignal<OwpenbotStatus | null>(null);

  const opencodeStatusMeta = createMemo(() => ({
    dot: props.clientConnected ? "bg-green-9" : "bg-gray-6",
    text: props.clientConnected ? "text-green-11" : "text-gray-10",
    label: props.clientConnected ? "Connected" : "Not connected",
  }));

  const openworkStatusMeta = createMemo(() => {
    switch (props.openworkServerStatus) {
      case "connected":
        return { dot: "bg-green-9", text: "text-green-11", label: "Ready" };
      case "limited":
        return { dot: "bg-amber-9", text: "text-amber-11", label: "Limited access" };
      default:
        return { dot: "bg-gray-6", text: "text-gray-10", label: "Unavailable" };
    }
  });

  const messagingMeta = createMemo(() => {
    const status = owpenbotStatus();
    if (!status) {
      return { dot: "bg-gray-6", text: "text-gray-10", label: "Messaging bridge unavailable" };
    }
    const whatsappLinked = status.whatsapp.linked;
    const telegramConfigured = status.telegram.configured;
    if (whatsappLinked && telegramConfigured) {
      return { dot: "bg-green-9", text: "text-green-11", label: "Messaging bridge ready" };
    }
    if (whatsappLinked || telegramConfigured || status.running) {
      return { dot: "bg-amber-9", text: "text-amber-11", label: "Messaging bridge setup" };
    }
    return { dot: "bg-gray-6", text: "text-gray-10", label: "Messaging bridge offline" };
  });

  const messagingHint = createMemo(() => {
    const status = owpenbotStatus();
    if (!status) return "";
    const hints: string[] = [];
    if (!status.telegram.configured) hints.push("Connect Telegram");
    if (!status.whatsapp.linked) hints.push("Connect WhatsApp (Alpha)");
    return hints.join(" / ");
  });

  const refreshOwpenbot = async () => {
    const next = await getOwpenbotStatus();
    setOwpenbotStatus(next);
  };

  onMount(() => {
    refreshOwpenbot();
    const interval = window.setInterval(refreshOwpenbot, 15_000);
    onCleanup(() => window.clearInterval(interval));
  });

  return (
    <div class="border-t border-gray-6 bg-gray-1/90 backdrop-blur-md">
      <div class="mx-auto max-w-5xl px-4 py-2 flex flex-wrap items-center gap-3 text-xs">
        <div
          class="flex items-center gap-2"
          title={`OpenCode Engine: ${opencodeStatusMeta().label}`}
        >
          <span class={`w-2 h-2 rounded-full ${opencodeStatusMeta().dot}`} />
          <Cpu class="w-4 h-4 text-gray-11" />
          <Show when={props.developerMode}>
            <span class="text-gray-11 font-medium">OpenCode</span>
            <span class={opencodeStatusMeta().text}>{opencodeStatusMeta().label}</span>
          </Show>
        </div>
        <div class="w-px h-4 bg-gray-6/70" />
        <div
          class="flex items-center gap-2"
          title={`OpenWork Server: ${openworkStatusMeta().label}`}
        >
          <span class={`w-2 h-2 rounded-full ${openworkStatusMeta().dot}`} />
          <Server class="w-4 h-4 text-gray-11" />
          <Show when={props.developerMode}>
            <span class="text-gray-11 font-medium">OpenWork</span>
            <span class={openworkStatusMeta().text}>{openworkStatusMeta().label}</span>
          </Show>
        </div>
        <div class="ml-auto flex items-center gap-2">
          <Button
            variant="ghost"
            class="h-7 px-2.5 py-0 text-xs"
            onClick={props.onOpenMessaging}
            title={messagingMeta().label}
          >
            <span class="relative">
              <MessageCircle class={`w-4 h-4 ${messagingMeta().text}`} />
              <span class={`absolute -right-1 -bottom-1 w-2 h-2 rounded-full ${messagingMeta().dot}`} />
            </span>
            <Show when={props.developerMode}>
              <span class="text-gray-11 font-medium">Messaging</span>
            </Show>
            <Show when={messagingHint()}>
              <span class="hidden md:inline text-[10px] uppercase tracking-wide text-gray-9/70 animate-pulse">
                {messagingHint()}
              </span>
            </Show>
          </Button>
          <Button
            variant="ghost"
            class="h-7 px-2.5 py-0 text-xs"
            onClick={props.onOpenSettings}
            title="Settings"
          >
            <Settings class="w-4 h-4" />
            <Show when={props.developerMode}>
              <span class="text-gray-11 font-medium">Settings</span>
            </Show>
          </Button>
          <Show when={props.mode === "host"}>
            <Button
              variant="danger"
              onClick={props.stopHost}
              disabled={props.busy}
              class="text-xs h-7 px-2.5 py-0"
            >
              Stop & Disconnect
            </Button>
          </Show>
          <Show when={props.mode === "client"}>
            <Button
              variant="outline"
              onClick={props.stopHost}
              disabled={props.busy}
              class="text-xs h-7 px-2.5 py-0"
            >
              Disconnect
            </Button>
          </Show>
        </div>
      </div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/text-input.tsx
================================================
import { splitProps, JSX } from "solid-js";

type TextInputProps = JSX.InputHTMLAttributes<HTMLInputElement> & {
  label?: string;
  hint?: string;
};

export default function TextInput(props: TextInputProps) {
  const [local, rest] = splitProps(props, ["label", "hint", "class", "ref"]);

  return (
    <label class="block">
      {local.label ? (
        <div class="mb-1 text-xs font-medium text-gray-11">{local.label}</div>
      ) : null}
      <input
        {...rest}
        ref={local.ref}
        class={`w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20 ${
          local.class ?? ""
        }`.trim()}
      />
      {local.hint ? <div class="mt-1 text-xs text-gray-10">{local.hint}</div> : null}
    </label>
  );
}



================================================
FILE: packages/app/src/app/components/thinking-block.tsx
================================================
import { For, Show, createMemo, createSignal } from "solid-js";

import { CheckCircle2, ChevronRight, Circle, RefreshCcw, X, Zap } from "lucide-solid";

export type ThinkingStep = {
  status: "pending" | "running" | "completed" | "error";
  text: string;
};

export default function ThinkingBlock(props: {
  steps: ThinkingStep[];
  maxWidthClass?: string;
}) {
  const [expanded, setExpanded] = createSignal(false);

  const activeStep = createMemo(() => {
    const steps = props.steps;
    return steps.find((s) => s.status === "running") ?? steps[steps.length - 1] ?? null;
  });

  return (
    <Show when={props.steps.length > 0}>
      <div class={props.maxWidthClass ?? "w-full max-w-[85%]"}>
        <button
          type="button"
          onClick={() => setExpanded((v) => !v)}
          class="flex items-center gap-2 text-xs font-medium text-gray-10 hover:text-gray-12 transition-colors py-1 px-2 rounded-lg hover:bg-gray-2/40"
        >
          <div class="p-1 rounded bg-gray-2 border border-gray-6 text-gray-10">
            <Zap size={12} />
          </div>
          <span class="truncate">{activeStep()?.text ?? "Working…"}</span>
          <ChevronRight
            size={12}
            class={`text-gray-7 transition-transform ${expanded() ? "rotate-90" : ""}`}
          />
        </button>

        <Show when={expanded()}>
          <div class="mt-2 ml-2 pl-4 border-l border-gray-6 space-y-2 animate-in slide-in-from-top-12 duration-150">
            <For each={props.steps}>
              {(step) => (
                <div class="flex items-start gap-3 text-xs text-gray-11 font-mono">
                  <div class="mt-0.5">
                    <Show
                      when={step.status === "completed"}
                      fallback={
                        <Show
                          when={step.status === "running"}
                          fallback={
                            <Show
                              when={step.status === "error"}
                              fallback={<Circle size={12} class="text-gray-8" />}
                            >
                              <X size={12} class="text-red-11" />
                            </Show>
                          }
                        >
                          <RefreshCcw size={12} class="text-blue-11 animate-spin" />
                        </Show>
                      }
                    >
                      <CheckCircle2 size={12} class="text-green-6" />
                    </Show>
                  </div>
                  <span class="leading-relaxed">{step.text}</span>
                </div>
              )}
            </For>
          </div>
        </Show>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/workspace-chip.tsx
================================================
import type { WorkspaceInfo } from "../lib/tauri";

import { t, currentLocale } from "../../i18n";

import { ChevronDown, Folder, Globe, Loader2, Zap } from "lucide-solid";

function iconForWorkspace(preset: string, workspaceType: string) {
  if (workspaceType === "remote") return Globe;
  if (preset === "starter") return Zap;
  if (preset === "automation") return Folder;
  if (preset === "minimal") return Globe;
  return Folder;
}

export default function WorkspaceChip(props: {
  workspace: WorkspaceInfo;
  onClick: () => void;
  connecting?: boolean;
}) {
  const Icon = iconForWorkspace(props.workspace.preset, props.workspace.workspaceType);
  const subtitle = () =>
    props.workspace.workspaceType === "remote"
      ? props.workspace.baseUrl ?? props.workspace.path
      : props.workspace.path;
  const translate = (key: string) => t(key, currentLocale());

  return (
    <button
      onClick={props.onClick}
      class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-2 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-4 transition-all group"
    >
      <div
        class={`p-1 rounded ${
          props.workspace.workspaceType !== "remote" && props.workspace.preset === "starter"
            ? "bg-amber-7/10 text-amber-6"
            : "bg-indigo-7/10 text-indigo-6"
        }`}
      >
        <Icon size={14} />
      </div>
      <div class="flex flex-col items-start mr-2 min-w-0">
        <div class="flex items-center gap-2">
          <span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[9.5rem]">
            {props.workspace.name}
          </span>
          {props.workspace.workspaceType === "remote" ? (
            <span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
              {translate("dashboard.remote")}
            </span>
          ) : null}
        </div>
        <span class="text-[10px] text-gray-10 font-mono leading-none max-w-[120px] truncate">
          {subtitle()}
        </span>
      </div>
      <ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
      {props.connecting ? <Loader2 size={14} class="text-gray-10 animate-spin" /> : null}
    </button>
  );
}



================================================
FILE: packages/app/src/app/components/workspace-picker.tsx
================================================
import { For, Show, createEffect, createMemo } from "solid-js";

import { Check, Globe, Loader2, Plus, Search, Trash2, Upload } from "lucide-solid";
import { t, currentLocale } from "../../i18n";

import type { WorkspaceInfo } from "../lib/tauri";

export default function WorkspacePicker(props: {
  open: boolean;
  workspaces: WorkspaceInfo[];
  activeWorkspaceId: string;
  search: string;
  onSearch: (value: string) => void;
  onClose: () => void;
  onSelect: (workspaceId: string) => Promise<boolean> | boolean | void;
  onCreateLocal: () => void;
  onCreateRemote: () => void;
  onImport: () => void;
  importing?: boolean;
  onForget: (workspaceId: string) => void;
  connectingWorkspaceId?: string | null;
}) {
  const translate = (key: string) => t(key, currentLocale());

  const filtered = createMemo(() => {
    const query = props.search.trim().toLowerCase();
    if (!query) return props.workspaces;
    return props.workspaces.filter((w) =>
      `${w.name} ${w.path} ${w.baseUrl ?? ""} ${w.displayName ?? ""} ${w.directory ?? ""} ${
        w.openworkHostUrl ?? ""
      } ${w.openworkWorkspaceName ?? ""}`
        .toLowerCase()
        .includes(query)
    );
  });

  const totalCount = createMemo(() => props.workspaces.length);
  let searchInputRef: HTMLInputElement | undefined;

  createEffect(() => {
    if (props.open) {
      requestAnimationFrame(() => searchInputRef?.focus());
    }
  });

  return (
    <Show when={props.open}>
      <div
        class="fixed inset-0 z-50 flex items-start justify-center pt-24 bg-gray-1/20 backdrop-blur-[2px]"
        onClick={props.onClose}
      >
        <div
          class="bg-gray-2 border border-gray-6 w-full max-w-sm rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-100"
          onClick={(e) => e.stopPropagation()}
        >
          <div class="p-2 border-b border-gray-6">
            <div class="relative">
              <Search size={14} class="absolute left-3 top-2.5 text-gray-10" />
              <input
                ref={(el) => (searchInputRef = el)}
                type="text"
                placeholder={translate("dashboard.find_workspace")}
                value={props.search}
                onInput={(e) => props.onSearch(e.currentTarget.value)}
                class="w-full bg-gray-1 border border-gray-6 rounded-lg py-1.5 pl-9 pr-3 text-sm text-gray-12 focus:outline-none focus:border-gray-7"
              />
            </div>
          </div>

          <div class="max-h-64 overflow-y-auto p-1">
            <div class="px-3 py-2 text-[10px] font-semibold text-gray-10 uppercase tracking-wider">
              {translate("dashboard.workspaces")} ({totalCount()})
            </div>

            <For each={filtered()}>
              {(ws) => (
                <div
                  class={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
                    props.activeWorkspaceId === ws.id
                      ? "bg-gray-4 text-gray-12"
                      : "text-gray-11 hover:text-gray-12 hover:bg-gray-4/50"
                  }`}
                >
                  <button
                    onClick={() => {
                      const result = props.onSelect(ws.id);
                      if (result instanceof Promise) {
                        result.then((ok) => {
                          if (ok !== false) props.onClose();
                        });
                        return;
                      }
                      if (result !== false) props.onClose();
                    }}
                    class="flex-1 text-left min-w-0"
                  >
                    <div class="flex items-center gap-2">
                      <div class="font-medium truncate">{ws.name}</div>
                      <Show when={ws.workspaceType === "remote"}>
                        <span class="inline-flex items-center gap-1 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-3 text-gray-11">
                          <Globe size={10} />
                          {translate("dashboard.remote")}
                        </span>
                        <span class="inline-flex items-center text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-2 text-gray-10">
                          {ws.remoteType === "openwork"
                            ? translate("dashboard.remote_connection_openwork")
                            : translate("dashboard.remote_connection_direct")}
                        </span>
                      </Show>
                    </div>
                    <div class="text-[10px] text-gray-7 font-mono truncate max-w-[200px]">
                      {ws.workspaceType === "remote"
                        ? ws.remoteType === "openwork"
                          ? ws.openworkHostUrl ?? ws.baseUrl ?? ws.path
                          : ws.baseUrl ?? ws.path
                        : ws.path}
                    </div>
                    <Show
                      when={
                        ws.workspaceType === "remote" &&
                        (ws.directory || ws.openworkWorkspaceName)
                      }
                    >
                      <div class="text-[10px] text-gray-8 truncate max-w-[200px]">
                        {ws.openworkWorkspaceName ?? ws.directory}
                      </div>
                    </Show>
                  </button>
                  <Show when={props.activeWorkspaceId === ws.id}>
                    <Check size={14} class="text-indigo-11" />
                  </Show>
                  <Show when={props.connectingWorkspaceId === ws.id}>
                    <Loader2 size={14} class="text-gray-10 animate-spin" />
                  </Show>
                  <button
                    type="button"
                    onClick={(event) => {
                      event.stopPropagation();
                      props.onForget(ws.id);
                    }}
                    class="p-1 rounded-md text-gray-9 hover:text-gray-12 hover:bg-gray-3 transition-colors"
                    title={translate("dashboard.forget_workspace")}
                  >
                    <Trash2 size={14} />
                  </button>
                </div>
              )}
            </For>
          </div>

          <div class="p-2 border-t border-gray-6 bg-gray-2">
            <div class="grid gap-2">
              <button
                onClick={() => {
                  props.onImport();
                  props.onClose();
                }}
                disabled={props.importing}
                class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors disabled:opacity-60 disabled:hover:bg-transparent"
              >
                <Upload size={16} />
                Import workspace config
              </button>
              <button
                onClick={() => {
                  props.onCreateLocal();
                  props.onClose();
                }}
                class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
              >
                <Plus size={16} />
                {translate("dashboard.new_workspace")}
              </button>
              <button
                onClick={() => {
                  props.onCreateRemote();
                  props.onClose();
                }}
                class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
              >
                <Globe size={16} />
                {translate("dashboard.new_remote_workspace")}
              </button>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/workspace-switch-overlay.tsx
================================================
import { Show, createMemo } from "solid-js";
import { t, currentLocale } from "../../i18n";
import OpenWorkLogo from "./openwork-logo";

import type { WorkspaceInfo } from "../lib/tauri";

export default function WorkspaceSwitchOverlay(props: {
  open: boolean;
  workspace: WorkspaceInfo | null;
  statusKey: string;
}) {
  const translate = (key: string) => t(key, currentLocale());

  const workspaceName = createMemo(() => {
    if (!props.workspace) return "";
    if (props.workspace.workspaceType === "remote" && props.workspace.remoteType === "openwork") {
      return (
        props.workspace.openworkWorkspaceName?.trim() ||
        props.workspace.displayName?.trim() ||
        props.workspace.name?.trim() ||
        props.workspace.openworkHostUrl?.trim() ||
        props.workspace.baseUrl?.trim() ||
        props.workspace.path?.trim() ||
        ""
      );
    }
    return (
      props.workspace.displayName?.trim() ||
      props.workspace.name?.trim() ||
      props.workspace.baseUrl?.trim() ||
      props.workspace.path?.trim() ||
      ""
    );
  });

  const title = createMemo(() => {
    const name = workspaceName();
    if (!name) return translate("workspace.switching_title_unknown");
    return translate("workspace.switching_title").replace("{name}", name);
  });

  const subtitle = createMemo(() => translate("workspace.switching_subtitle"));

  const statusLine = createMemo(() => {
    if (props.statusKey) return translate(props.statusKey);
    return translate("workspace.switching_status_loading");
  });

  const metaPrimary = createMemo(() => {
    if (!props.workspace) return "";
    if (props.workspace.workspaceType === "remote") {
      if (props.workspace.remoteType === "openwork") {
        return props.workspace.openworkHostUrl?.trim() ?? props.workspace.baseUrl?.trim() ?? "";
      }
      return props.workspace.baseUrl?.trim() ?? "";
    }
    return props.workspace.path?.trim() ?? "";
  });

  const metaSecondary = createMemo(() => {
    if (!props.workspace || props.workspace.workspaceType !== "remote") return "";
    return (
      props.workspace.directory?.trim() ||
      props.workspace.openworkWorkspaceName?.trim() ||
      ""
    );
  });

  return (
    <Show when={props.open}>
      <div class="fixed inset-0 z-[60] overflow-hidden bg-gray-1 text-gray-12 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300">
        <div class="absolute inset-0">
          <div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-80" />
          <div
            class="absolute -top-24 right-[-4rem] h-72 w-72 rounded-full bg-indigo-7/20 blur-3xl motion-safe:animate-pulse motion-reduce:opacity-40"
            style={{ "animation-duration": "6s" }}
          />
          <div
            class="absolute -bottom-28 left-[-5rem] h-80 w-80 rounded-full bg-indigo-6/15 blur-3xl motion-safe:animate-pulse motion-reduce:opacity-40"
            style={{ "animation-duration": "8s" }}
          />
          <div class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-gray-1 via-gray-1/40 to-transparent" />
        </div>

        <div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-6 py-10 text-center">
          <div class="flex flex-col items-center gap-8">
                      <div class="relative">

              <div class="relative h-24 w-24 flex items-center justify-center">
                <OpenWorkLogo size={44} class="drop-shadow-sm" />
              </div>
            </div>

            <div class="space-y-2">
              <div class="flex items-center justify-center gap-2">
                <h2 class="text-2xl font-semibold tracking-tight">{title()}</h2>
                <Show when={props.workspace?.workspaceType === "remote"}>
                  <span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
                    {translate("dashboard.remote")}
                  </span>
                  <span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-3 text-gray-10">
                    {props.workspace?.remoteType === "openwork"
                      ? translate("dashboard.remote_connection_openwork")
                      : translate("dashboard.remote_connection_direct")}
                  </span>
                </Show>
              </div>
              <p class="text-sm text-gray-10">{subtitle()}</p>
            </div>

            <div class="flex flex-col items-center gap-3">
              <div class="flex items-center gap-2 text-sm text-gray-11">
                <span class="relative flex h-2.5 w-2.5">
                  <span
                    class="absolute inline-flex h-full w-full rounded-full bg-indigo-7/40 motion-safe:animate-ping motion-reduce:opacity-40"
                    style={{ "animation-duration": "2.6s" }}
                  />
                  <span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-indigo-7/70" />
                </span>
                <span>{statusLine()}</span>
              </div>
              <div class="h-1 w-56 overflow-hidden rounded-full bg-gray-4/50">
                <div class="h-full w-1/2 rounded-full bg-gradient-to-r from-transparent via-indigo-6/50 to-transparent animate-progress-shimmer" />
              </div>
            </div>

            <div class="space-y-1 text-[11px] text-gray-8 font-mono">
              <Show when={metaPrimary()}>
                <div class="truncate max-w-[22rem]">{metaPrimary()}</div>
              </Show>
              <Show when={metaSecondary()}>
                <div class="truncate max-w-[22rem] text-gray-7">{metaSecondary()}</div>
              </Show>
            </div>
          </div>
        </div>
      </div>
    </Show>
  );
}



================================================
FILE: packages/app/src/app/components/session/composer.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import type { Agent } from "@opencode-ai/sdk/v2/client";
import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid";

import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types";

export type CommandItem = {
  id: string;
  description: string;
  needsArgs?: boolean;
};

type MentionOption = {
  id: string;
  kind: "agent" | "file";
  label: string;
  value: string;
  detail?: string;
};

type MentionSection = {
  title: string;
  options: MentionOption[];
};

type ComposerProps = {
  prompt: string;
  busy: boolean;
  onSend: (draft: ComposerDraft) => void;
  onDraftChange: (draft: ComposerDraft) => void;
  commandMatches: CommandItem[];
  onRunCommand: (commandId: string) => void;
  onInsertCommand: (commandId: string) => void;
  selectedModelLabel: string;
  onModelClick: () => void;
  modelVariantLabel: string;
  modelVariant: string | null;
  onModelVariantChange: (value: string) => void;
  agentLabel: string;
  selectedAgent: string | null;
  agentPickerOpen: boolean;
  agentPickerBusy: boolean;
  agentPickerError: string | null;
  agentOptions: Agent[];
  onToggleAgentPicker: () => void;
  onSelectAgent: (agent: string | null) => void;
  setAgentPickerRef: (el: HTMLDivElement) => void;
  showNotionBanner: boolean;
  onNotionBannerClick: () => void;
  toast: string | null;
  onToast: (message: string) => void;
  listAgents: () => Promise<Agent[]>;
  recentFiles: string[];
  searchFiles: (query: string) => Promise<string[]>;
  isRemoteWorkspace: boolean;
};

const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"];

const isImageMime = (mime: string) => ACCEPTED_IMAGE_TYPES.includes(mime);

const fileToDataUrl = (file: File) =>
  new Promise<string>((resolve, reject) => {
    const reader = new FileReader();
    reader.onerror = () => reject(new Error("Failed to read attachment"));
    reader.onload = () => {
      const result = typeof reader.result === "string" ? reader.result : "";
      resolve(result);
    };
    reader.readAsDataURL(file);
  });

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

const normalizeText = (value: string) => value.replace(/\u00a0/g, " ");

const MODEL_VARIANT_OPTIONS = [
  { value: "none", label: "None" },
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
  { value: "xhigh", label: "X-High" },
];

const partsToText = (parts: ComposerPart[]) =>
  parts
    .map((part) => {
      if (part.type === "text") return part.text;
      if (part.type === "agent") return `@${part.name}`;
      return `@${part.label ?? part.path}`;
    })
    .join("");

const createMentionSpan = (part: Extract<ComposerPart, { type: "agent" | "file" }>) => {
  const span = document.createElement("span");
  const label = part.type === "agent" ? part.name : part.label ?? part.path;
  span.textContent = `@${label}`;
  span.contentEditable = "false";
  span.dataset.mentionKind = part.type;
  span.dataset.mentionValue = part.type === "agent" ? part.name : part.path;
  span.dataset.mentionLabel = label;
  span.className =
    "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-gray-12/10 text-gray-12 border border-gray-6/70";
  return span;
};

const insertTextWithBreaks = (target: HTMLElement, text: string) => {
  const chunks = text.split("\n");
  chunks.forEach((chunk, index) => {
    if (chunk.length) {
      target.appendChild(document.createTextNode(chunk));
    }
    if (index < chunks.length - 1) {
      target.appendChild(document.createElement("br"));
    }
  });
};

const buildPartsFromEditor = (root: HTMLElement): ComposerPart[] => {
  const parts: ComposerPart[] = [];
  const pushText = (text: string) => {
    if (!text) return;
    const last = parts[parts.length - 1];
    if (last?.type === "text") {
      last.text += text;
      return;
    }
    parts.push({ type: "text", text });
  };

  const walk = (node: Node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      pushText(node.textContent ?? "");
      return;
    }
    if (node.nodeType !== Node.ELEMENT_NODE) return;
    const el = node as HTMLElement;
    if (el.dataset.mentionKind) {
      const kind = el.dataset.mentionKind === "agent" ? "agent" : "file";
      if (kind === "agent") {
        parts.push({ type: "agent", name: el.dataset.mentionValue ?? "" });
      } else {
        parts.push({ type: "file", path: el.dataset.mentionValue ?? "", label: el.dataset.mentionLabel ?? undefined });
      }
      return;
    }
    if (el.tagName === "BR") {
      pushText("\n");
      return;
    }
    if (el.tagName === "DIV") {
      if (!el.childNodes.length) {
        pushText("\n");
        return;
      }
      el.childNodes.forEach(walk);
      pushText("\n");
      return;
    }
    el.childNodes.forEach(walk);
  };

  root.childNodes.forEach(walk);
  return parts;
};

const getSelectionOffsets = (root: HTMLElement) => {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return null;
  const range = selection.getRangeAt(0);
  const startRange = range.cloneRange();
  startRange.selectNodeContents(root);
  startRange.setEnd(range.startContainer, range.startOffset);
  const endRange = range.cloneRange();
  endRange.selectNodeContents(root);
  endRange.setEnd(range.endContainer, range.endOffset);
  return {
    start: startRange.toString().length,
    end: endRange.toString().length,
  };
};

const restoreSelectionOffsets = (root: HTMLElement, offsets: { start: number; end: number }) => {
  const selection = window.getSelection();
  if (!selection) return;

  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
  let node: Node | null;
  let current = 0;
  let startNode: Node | null = null;
  let endNode: Node | null = null;
  let startOffset = 0;
  let endOffset = 0;

  while ((node = walker.nextNode())) {
    const length = node.textContent?.length ?? 0;
    if (!startNode && current + length >= offsets.start) {
      startNode = node;
      startOffset = offsets.start - current;
    }
    if (!endNode && current + length >= offsets.end) {
      endNode = node;
      endOffset = offsets.end - current;
      break;
    }
    current += length;
  }

  const range = document.createRange();
  if (!startNode || !endNode) {
    range.selectNodeContents(root);
    range.collapse(false);
  } else {
    range.setStart(startNode, clamp(startOffset, 0, (startNode.textContent ?? "").length));
    range.setEnd(endNode, clamp(endOffset, 0, (endNode.textContent ?? "").length));
  }
  selection.removeAllRanges();
  selection.addRange(range);
};

const buildRangeFromOffsets = (root: HTMLElement, start: number, end: number) => {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
  let node: Node | null;
  let current = 0;
  let startNode: Node | null = null;
  let endNode: Node | null = null;
  let startOffset = 0;
  let endOffset = 0;

  while ((node = walker.nextNode())) {
    const length = node.textContent?.length ?? 0;
    if (!startNode && current + length >= start) {
      startNode = node;
      startOffset = start - current;
    }
    if (!endNode && current + length >= end) {
      endNode = node;
      endOffset = end - current;
      break;
    }
    current += length;
  }

  const range = document.createRange();
  if (!startNode || !endNode) {
    range.selectNodeContents(root);
    range.collapse(false);
    return range;
  }
  range.setStart(startNode, clamp(startOffset, 0, (startNode.textContent ?? "").length));
  range.setEnd(endNode, clamp(endOffset, 0, (endNode.textContent ?? "").length));
  return range;
};

export default function Composer(props: ComposerProps) {
  let editorRef: HTMLDivElement | undefined;
  let fileInputRef: HTMLInputElement | undefined;
  let variantPickerRef: HTMLDivElement | undefined;
  let suppressPromptSync = false;
  const [commandIndex, setCommandIndex] = createSignal(0);
  const [mentionIndex, setMentionIndex] = createSignal(0);
  const [mentionQuery, setMentionQuery] = createSignal("");
  const [mentionOpen, setMentionOpen] = createSignal(false);
  const [agentOptions, setAgentOptions] = createSignal<Agent[]>([]);
  const [agentLoaded, setAgentLoaded] = createSignal(false);
  const [searchResults, setSearchResults] = createSignal<string[]>([]);
  const [searchLoading, setSearchLoading] = createSignal(false);
  const [attachments, setAttachments] = createSignal<ComposerAttachment[]>([]);
  const [mode, setMode] = createSignal<PromptMode>("prompt");
  const [historySnapshot, setHistorySnapshot] = createSignal<ComposerDraft | null>(null);
  const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 });
  const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] });
  const [variantMenuOpen, setVariantMenuOpen] = createSignal(false);
  const activeVariant = createMemo(() => props.modelVariant ?? "none");

  onMount(() => {
    queueMicrotask(() => focusEditorEnd());
  });

  const commandMenuOpen = createMemo(() => {
    const prompt = props.prompt.trim();
    // Close menu if user has typed a space (likely typing args)
    if (prompt.includes(" ")) return false;
    return prompt.startsWith("/") && !props.busy && mode() === "prompt" && !mentionOpen();
  });

  createEffect(() => {
    if (commandMenuOpen()) {
      props.prompt; setCommandIndex(0);
    }
  });

  createEffect(() => {
    if (mentionOpen()) {
      mentionQuery(); setMentionIndex(0);
    }
  });

  const mentionSections = createMemo<MentionSection[]>(() => {
    if (!mentionOpen()) return [];
    const query = mentionQuery().trim().toLowerCase();
    const agents = agentOptions()
      .filter((agent: Agent) => {
        if (!query) return true;
        return agent.name.toLowerCase().includes(query);
      })
      .map((agent: Agent) => ({
        id: `agent:${agent.name}`,
        kind: "agent" as const,
        label: agent.name,
        value: agent.name,
      }));

    const recentFiles = props.recentFiles
      .filter((file: string) => {
        if (!query) return true;
        return file.toLowerCase().includes(query);
      })
      .map((file: string) => ({
        id: `file:${file}`,
        kind: "file" as const,
        label: file.split(/[/\\]/).pop() ?? file,
        value: file,
        detail: file,
      }));

    const searchFiles = searchResults()
      .filter((file: string) => file && !recentFiles.find((recent) => recent.value === file))
      .map((file: string) => ({
        id: `search:${file}`,
        kind: "file" as const,
        label: file.split(/[/\\]/).pop() ?? file,
        value: file,
        detail: file,
      }));

    const sections: MentionSection[] = [];
    if (agents.length) sections.push({ title: "Agents", options: agents });
    if (recentFiles.length) sections.push({ title: "Recent files", options: recentFiles });
    if (searchFiles.length) sections.push({ title: "Search results", options: searchFiles });
    if (!searchFiles.length && query) {
      sections.push({
        title: "Use path",
        options: [
          {
            id: `manual:${query}`,
            kind: "file",
            label: query.split(/[/\\]/).pop() ?? query,
            value: query,
            detail: query,
          },
        ],
      });
    }
    return sections;
  });

  const mentionOptions = createMemo(() =>
    mentionSections().flatMap((section: MentionSection) => section.options)
  );

  const syncHeight = () => {
    if (!editorRef) return;
    editorRef.style.height = "auto";
    const baseHeight = 24;
    const scrollHeight = editorRef.scrollHeight || baseHeight;
    const nextHeight = Math.min(Math.max(scrollHeight, baseHeight), 160);
    editorRef.style.height = `${nextHeight}px`;
    editorRef.style.overflowY = editorRef.scrollHeight > 160 ? "auto" : "hidden";
  };

  const emitDraftChange = () => {
    if (!editorRef) return;
    const parts = buildPartsFromEditor(editorRef);
    const text = normalizeText(partsToText(parts));
    suppressPromptSync = true;
    props.onDraftChange({
      mode: mode(),
      parts,
      attachments: attachments(),
      text,
    });
    queueMicrotask(() => {
      suppressPromptSync = false;
    });
    syncHeight();
  };

  const focusEditorEnd = () => {
    if (!editorRef) return;
    const selection = window.getSelection();
    if (!selection) return;
    const range = document.createRange();
    range.selectNodeContents(editorRef);
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
    editorRef.focus();
  };

  const renderParts = (parts: ComposerPart[], keepSelection = true) => {
    if (!editorRef) return;
    const selection = keepSelection ? getSelectionOffsets(editorRef) : null;
    editorRef.innerHTML = "";
    parts.forEach((part) => {
      if (part.type === "text") {
        insertTextWithBreaks(editorRef!, part.text);
        return;
      }
      const span = createMentionSpan(part);
      editorRef?.appendChild(span);
      editorRef?.appendChild(document.createTextNode(" "));
    });
    if (selection) {
      restoreSelectionOffsets(editorRef, selection);
    }
    syncHeight();
  };

  const setEditorText = (value: string) => {
    if (!editorRef) return;
    renderParts(value ? [{ type: "text", text: value }] : [], false);
  };

  const updateMentionQuery = () => {
    if (!editorRef) return;
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0) {
      setMentionOpen(false);
      return;
    }
    const range = selection.getRangeAt(0);
    const beforeRange = range.cloneRange();
    beforeRange.selectNodeContents(editorRef);
    beforeRange.setEnd(range.endContainer, range.endOffset);
    const beforeText = normalizeText(beforeRange.toString());
    const atIndex = beforeText.lastIndexOf("@");
    if (atIndex === -1) {
      setMentionOpen(false);
      return;
    }
    const query = beforeText.slice(atIndex + 1);
    if (/\s/.test(query)) {
      setMentionOpen(false);
      return;
    }
    setMentionQuery(query);
    setMentionOpen(true);
  };

  const insertMention = (option: MentionOption) => {
    if (!editorRef) return;
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0) return;
    const range = selection.getRangeAt(0);
    const beforeRange = range.cloneRange();
    beforeRange.selectNodeContents(editorRef);
    beforeRange.setEnd(range.endContainer, range.endOffset);
    const beforeText = normalizeText(beforeRange.toString());
    const atIndex = beforeText.lastIndexOf("@");
    if (atIndex === -1) return;
    const start = atIndex;
    const end = beforeText.length;
    const deleteRange = buildRangeFromOffsets(editorRef, start, end);
    deleteRange.deleteContents();

    const mentionPart =
      option.kind === "agent"
        ? ({ type: "agent", name: option.value } as ComposerPart)
        : ({ type: "file", path: option.value, label: option.label } as ComposerPart);
    const mentionNode = createMentionSpan(mentionPart as Extract<ComposerPart, { type: "agent" | "file" }>);
    deleteRange.insertNode(mentionNode);
    mentionNode.after(document.createTextNode(" "));

    const cursor = document.createRange();
    cursor.setStartAfter(mentionNode.nextSibling ?? mentionNode);
    cursor.collapse(true);
    selection.removeAllRanges();
    selection.addRange(cursor);
    setMentionOpen(false);
    setMentionQuery("");
    emitDraftChange();
  };

  const canNavigateHistory = () => {
    if (!editorRef) return false;
    const offsets = getSelectionOffsets(editorRef);
    if (!offsets || offsets.start !== offsets.end) return false;
    const total = normalizeText(editorRef.innerText).length;
    return offsets.start === 0 || offsets.start === total;
  };

  const applyHistoryDraft = (draft: ComposerDraft | null) => {
    if (!draft) return;
    setMode(draft.mode);
    renderParts(draft.parts, false);
    setAttachments(draft.attachments ?? []);
    props.onDraftChange(draft);
  };

  const navigateHistory = (direction: "up" | "down") => {
    const key = mode();
    const list = history()[key];
    if (!list.length) return;
    const index = historyIndex()[key];
    const nextIndex = direction === "up" ? index + 1 : index - 1;
    if (nextIndex < -1 || nextIndex >= list.length) return;

    if (index === -1 && direction === "up") {
      const parts = editorRef ? buildPartsFromEditor(editorRef) : [];
      const text = normalizeText(partsToText(parts));
      setHistorySnapshot({ mode: key, parts, attachments: attachments(), text });
    }

    setHistoryIndex((current: { prompt: number; shell: number }) => ({ ...current, [key]: nextIndex }));
    if (nextIndex === -1) {
      applyHistoryDraft(historySnapshot());
      setHistorySnapshot(null);
      return;
    }
    const target = list[list.length - 1 - nextIndex];
    applyHistoryDraft(target);
  };

  const sendDraft = () => {
    if (!editorRef) return;
    const parts = buildPartsFromEditor(editorRef);
    const text = normalizeText(partsToText(parts));
    const draft: ComposerDraft = { mode: mode(), parts, attachments: attachments(), text };
    recordHistory(draft);
    props.onSend(draft);
    setAttachments([]);
    setEditorText("");
    emitDraftChange();
    queueMicrotask(() => focusEditorEnd());
  };

  const recordHistory = (draft: ComposerDraft) => {
    const trimmed = draft.text.trim();
    if (!trimmed && !draft.attachments.length) return;
    setHistory((current: { prompt: ComposerDraft[]; shell: ComposerDraft[] }) => ({
      ...current,
      [draft.mode]: [...current[draft.mode], { ...draft, attachments: [] }],
    }));
    setHistoryIndex((current: { prompt: number; shell: number }) => ({ ...current, [draft.mode]: -1 }));
    setHistorySnapshot(null);
  };

  const addAttachments = async (files: File[]) => {
    if (props.isRemoteWorkspace) {
      props.onToast("Attachments are unavailable in remote workspaces.");
      return;
    }
    const next: ComposerAttachment[] = [];
    for (const file of files) {
      if (!ACCEPTED_FILE_TYPES.includes(file.type)) {
        props.onToast(`${file.name} is not a supported attachment type.`);
        continue;
      }
      if (file.size > MAX_ATTACHMENT_BYTES) {
        props.onToast(`${file.name} exceeds the 8MB limit.`);
        continue;
      }
      try {
        const dataUrl = await fileToDataUrl(file);
        next.push({
          id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
          name: file.name,
          mimeType: file.type || "application/octet-stream",
          size: file.size,
          kind: isImageMime(file.type) ? "image" : "file",
          dataUrl,
        });
      } catch (error) {
        props.onToast(error instanceof Error ? error.message : "Failed to read attachment");
      }
    }
    if (next.length) {
      setAttachments((current: ComposerAttachment[]) => [...current, ...next]);
      emitDraftChange();
    }
  };

  const handlePaste = (event: ClipboardEvent) => {
    if (!event.clipboardData) return;
    const clipboard = event.clipboardData;
    const fileItems = Array.from(clipboard.items || []).filter((item) => item.kind === "file");
    const files = Array.from(clipboard.files || []);
    const itemFiles = fileItems
      .map((item) => item.getAsFile())
      .filter((file): file is File => !!file);
    const allFiles = files.length ? files : itemFiles;
    if (!allFiles.length) return;
    event.preventDefault();
    const hasSupported = allFiles.some((file) => ACCEPTED_FILE_TYPES.includes(file.type));
    if (!hasSupported) {
      props.onToast("Unsupported attachment type.");
      return;
    }
    void addAttachments(allFiles);
  };

  const handleDrop = (event: DragEvent) => {
    if (!event.dataTransfer) return;
    event.preventDefault();
    const files = Array.from(event.dataTransfer.files || []);
    if (files.length) void addAttachments(files);
  };

  const handleKeyDown = (event: KeyboardEvent) => {
    if (event.key === "Enter" && event.shiftKey) {
      event.preventDefault();
      document.execCommand("insertLineBreak");
      emitDraftChange();
      return;
    }
    if (event.isComposing && event.key !== "Enter") return;

    if (mentionOpen()) {
      const options = mentionOptions();
      if (event.key === "Enter") {
        event.preventDefault();
        const active = options[mentionIndex()] ?? options[0];
        if (active) insertMention(active);
        return;
      }
      if (event.key === "ArrowDown") {
        event.preventDefault();
        setMentionIndex((i: number) => clamp(i + 1, 0, options.length - 1));
        return;
      }
      if (event.key === "ArrowUp") {
        event.preventDefault();
        setMentionIndex((i: number) => clamp(i - 1, 0, options.length - 1));
        return;
      }
      if (event.key === "Escape") {
        event.preventDefault();
        setMentionOpen(false);
        setMentionQuery("");
        return;
      }
      if (event.key === "Tab") {
        event.preventDefault();
        const active = options[mentionIndex()] ?? options[0];
        if (active) insertMention(active);
        return;
      }
    }

    if (commandMenuOpen()) {
      const matches = props.commandMatches;
      if (event.key === "Enter") {
        event.preventDefault();
        const active = matches[commandIndex()] ?? matches[0];
        if (active) {
          const hasArgs = /\s+/.test(props.prompt.trim());
          if (active.needsArgs && !hasArgs) {
            props.onInsertCommand(active.id);
          } else {
            props.onRunCommand(active.id);
          }
        }
        return;
      }
      if (event.key === "ArrowDown") {
        event.preventDefault();
        setCommandIndex((i: number) => Math.min(i + 1, matches.length - 1));
        return;
      }
      if (event.key === "ArrowUp") {
        event.preventDefault();
        setCommandIndex((i: number) => Math.max(i - 1, 0));
        return;
      }
      if (event.key === "Escape") {
        event.preventDefault();
        setEditorText("");
        emitDraftChange();
        return;
      }
      if (event.key === "Tab") {
        event.preventDefault();
        const active = matches[commandIndex()] ?? matches[0];
        if (active) {
          props.onInsertCommand(active.id);
        }
        return;
      }
    }

    if (event.key === "!" && mode() === "prompt") {
      const offsets = editorRef ? getSelectionOffsets(editorRef) : null;
      if (offsets && offsets.start === 0 && offsets.end === 0) {
        event.preventDefault();
        setMode("shell");
        emitDraftChange();
        return;
      }
    }

    if (event.key === "Escape" && mode() === "shell") {
      event.preventDefault();
      setMode("prompt");
      emitDraftChange();
      return;
    }

    if (event.key === "ArrowUp" || event.key === "ArrowDown") {
      if (canNavigateHistory()) {
        event.preventDefault();
        navigateHistory(event.key === "ArrowUp" ? "up" : "down");
        return;
      }
    }

    if (event.key === "Enter") {
      event.preventDefault();
      if (props.busy) return;
      sendDraft();
    }
  };

  createEffect(() => {
    if (!mentionOpen() || agentLoaded()) return;
    props
      .listAgents()
      .then((agents) => setAgentOptions(agents))
      .catch(() => setAgentOptions([]))
      .finally(() => setAgentLoaded(true));
  });

  createEffect(() => {
    if (!mentionOpen()) return;
    const query = mentionQuery().trim();
    if (!query) {
      setSearchResults([]);
      return;
    }
    setSearchLoading(true);
    const timeout = window.setTimeout(() => {
      props
        .searchFiles(query)
        .then((results) => setSearchResults(results))
        .catch(() => setSearchResults([]))
        .finally(() => setSearchLoading(false));
    }, 150);
    onCleanup(() => window.clearTimeout(timeout));
  });

  createEffect(() => {
    if (!editorRef) return;
    const value = props.prompt;
    const current = normalizeText(editorRef.innerText);
    if (suppressPromptSync) {
      if (!value && current) {
        setEditorText("");
        setAttachments([]);
        setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 }));
        setHistorySnapshot(null);
        queueMicrotask(() => focusEditorEnd());
      }
      return;
    }
    if (value === current) return;
    if (value.startsWith("!") && mode() === "prompt") {
      setMode("shell");
      setEditorText(value.slice(1).trimStart());
      emitDraftChange();
      queueMicrotask(() => focusEditorEnd());
      return;
    }
    setEditorText(value);
    if (!value) {
      setAttachments([]);
      setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 }));
      setHistorySnapshot(null);
    }
    emitDraftChange();
    queueMicrotask(() => focusEditorEnd());
  });

  createEffect(() => {
    if (!variantMenuOpen()) return;
    const handler = (event: MouseEvent) => {
      if (!variantPickerRef) return;
      if (variantPickerRef.contains(event.target as Node)) return;
      setVariantMenuOpen(false);
    };
    window.addEventListener("mousedown", handler);
    onCleanup(() => window.removeEventListener("mousedown", handler));
  });

  createEffect(() => {
    const handler = () => {
      editorRef?.focus();
    };
    window.addEventListener("openwork:focusPrompt", handler);
    onCleanup(() => window.removeEventListener("openwork:focusPrompt", handler));
  });

  return (
    <div class="p-4 border-t border-gray-6 bg-gray-1 sticky bottom-0 z-20">
      <div class="max-w-2xl mx-auto">
        <div
          class={`bg-gray-2 border border-gray-6 rounded-3xl overflow-visible transition-all shadow-2xl relative group/input ${
            commandMenuOpen() || mentionOpen()
              ? "rounded-t-none border-t-transparent"
              : "focus-within:ring-1 focus-within:ring-gray-7"
          }`}
          onDrop={handleDrop}
          onDragOver={(event: DragEvent) => {
            if (!props.isRemoteWorkspace) event.preventDefault();
          }}
        >
          <Show when={commandMenuOpen()}>
            <div class="absolute bottom-full left-[-1px] right-[-1px] z-30">
              <div class="rounded-t-3xl border border-gray-6 border-b-0 bg-gray-2 shadow-2xl overflow-hidden">
                <div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30 bg-gray-2">
                  Commands
                </div>
                <div class="space-y-1 p-2 bg-gray-2">
                  <Show
                    when={props.commandMatches.length}
                    fallback={<div class="px-3 py-2 text-xs text-gray-9">No commands found.</div>}
                  >
                    <For each={props.commandMatches}>
                      {(command: CommandItem, idx: () => number) => (
                        <button
                          type="button"
                          class={`w-full flex items-start gap-3 rounded-xl px-3 py-2 text-left transition-colors ${
                            idx() === commandIndex()
                              ? "bg-gray-12/10 text-gray-12"
                              : "text-gray-11 hover:bg-gray-12/5"
                          }`}
                          onMouseDown={(e: MouseEvent) => {
                            e.preventDefault();
                            if (command.needsArgs) {
                              props.onInsertCommand(command.id);
                            } else {
                              props.onRunCommand(command.id);
                            }
                          }}
                          onMouseEnter={() => setCommandIndex(idx())}
                        >
                          <div class="text-xs font-semibold text-gray-12">/{command.id}</div>
                          <div class="text-[11px] text-gray-9">{command.description}</div>
                        </button>
                      )}
                    </For>
                  </Show>
                </div>
              </div>
            </div>
          </Show>

          <Show when={mentionOpen()}>
            <div class="absolute bottom-full left-[-1px] right-[-1px] z-30">
              <div class="rounded-t-3xl border border-gray-6 border-b-0 bg-gray-2 shadow-2xl overflow-hidden">
                <div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30 bg-gray-2 flex items-center gap-2">
                  <AtSign size={12} />
                  Mentions
                </div>
                <div class="space-y-3 p-3 bg-gray-2 max-h-64 overflow-y-auto">
                  <Show
                    when={mentionOptions().length}
                    fallback={
                      <div class="px-3 py-2 text-xs text-gray-9">
                        {searchLoading() ? "Searching files..." : "No matches found."}
                      </div>
                    }
                  >
                    <For each={mentionSections()}>
                      {(section: MentionSection) => (
                        <div>
                          <div class="px-3 py-1 text-[10px] uppercase tracking-[0.2em] text-gray-8">
                            {section.title}
                          </div>
                          <div class="space-y-1">
                            <For each={section.options}>
                              {(option: MentionOption) => {
                                const optionIndex = createMemo(() => mentionOptions().findIndex((item) => item.id === option.id));
                                return (
                                  <button
                                    type="button"
                                    class={`w-full flex items-start gap-3 rounded-xl px-3 py-2 text-left transition-colors ${
                                      optionIndex() === mentionIndex()
                                        ? "bg-gray-12/10 text-gray-12"
                                        : "text-gray-11 hover:bg-gray-12/5"
                                    }`}
                                    onMouseDown={(e: MouseEvent) => {
                                      e.preventDefault();
                                      insertMention(option);
                                    }}
                                    onMouseEnter={() => setMentionIndex(optionIndex())}
                                  >
                                    <div class="text-xs font-semibold text-gray-12">
                                      {option.kind === "agent" ? `@${option.label}` : option.label}
                                    </div>
                                    <Show when={option.detail}>
                                      <div class="text-[11px] text-gray-9">{option.detail}</div>
                                    </Show>
                                  </button>
                                );
                              }}
                            </For>
                          </div>
                        </div>
                      )}
                    </For>
                  </Show>
                </div>
              </div>
            </div>
          </Show>

          <div class="absolute top-3 left-4 flex items-center gap-3 text-[10px] font-bold text-gray-7 uppercase tracking-widest z-10">
            <button
              type="button"
              class="flex items-center gap-1.5 text-gray-7 hover:text-gray-11 transition-colors"
              onClick={props.onModelClick}
              disabled={props.busy}
            >
              <Zap size={10} class="text-gray-7 group-hover:text-amber-11 transition-colors" />
              <span>{props.selectedModelLabel}</span>
            </button>
            <div class="relative z-40" ref={(el) => (variantPickerRef = el)}>
              <button
                type="button"
                class="flex items-center gap-2 rounded-full border border-gray-6/80 bg-gray-1/60 px-2 py-0.5 text-[9px] text-gray-9 hover:text-gray-11 hover:border-gray-7 transition-colors"
                onClick={() => setVariantMenuOpen((open) => !open)}
                disabled={props.busy}
                aria-expanded={variantMenuOpen()}
              >
                <span class="text-gray-8">Variant</span>
                <span class="font-mono text-gray-11">{props.modelVariantLabel}</span>
                <ChevronDown size={12} class="text-gray-8" />
              </button>
              <Show when={variantMenuOpen()}>
                <div class="absolute left-0 bottom-full mb-2 w-40 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden z-40">
                  <div class="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
                    Thinking effort
                  </div>
                  <div class="p-2 space-y-1">
                    <For each={MODEL_VARIANT_OPTIONS}>
                      {(option) => (
                        <button
                          type="button"
                          class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
                            activeVariant() === option.value
                              ? "bg-gray-12/10 text-gray-12"
                              : "text-gray-11 hover:bg-gray-12/5"
                          }`}
                          onClick={() => {
                            props.onModelVariantChange(option.value);
                            setVariantMenuOpen(false);
                          }}
                        >
                          <span>{option.label}</span>
                          <Show when={activeVariant() === option.value}>
                            <span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
                          </Show>
                        </button>
                      )}
                    </For>
                  </div>
                </div>
              </Show>
            </div>
          </div>

          <div class="p-3 pt-8 pb-3 px-4">
            <Show when={props.showNotionBanner}>
              <button
                type="button"
                class="w-full mb-2 flex items-center justify-between gap-3 rounded-xl border border-green-7/20 bg-green-7/10 px-3 py-2 text-left text-sm text-green-12 transition-colors hover:bg-green-7/15"
                onClick={props.onNotionBannerClick}
              >
                <span>Try it now: set up my CRM in Notion</span>
                <span class="text-xs text-green-12 font-medium">Insert prompt</span>
              </button>
            </Show>

            <Show when={attachments().length}>
              <div class="mb-3 flex flex-wrap gap-2">
                <For each={attachments()}>
                  {(attachment: ComposerAttachment) => (
                    <div class="flex items-center gap-2 rounded-2xl border border-gray-6 bg-gray-1/70 px-3 py-2 text-xs text-gray-11">
                      <Show
                        when={attachment.kind === "image"}
                        fallback={<File size={14} class="text-gray-9" />}
                      >
                        <div class="h-10 w-10 rounded-xl bg-gray-2 overflow-hidden border border-gray-6">
                          <img src={attachment.dataUrl} alt={attachment.name} class="h-full w-full object-cover" />
                        </div>
                      </Show>
                      <div class="max-w-[160px]">
                        <div class="truncate text-gray-12">{attachment.name}</div>
                        <div class="text-[10px] text-gray-9">
                          {attachment.kind === "image" ? "Image" : attachment.mimeType || "File"}
                        </div>
                      </div>
                      <button
                        type="button"
                        class="ml-1 rounded-full p-1 text-gray-9 hover:text-gray-12 hover:bg-gray-12/10"
                        onClick={() => {
                          setAttachments((current: ComposerAttachment[]) =>
                            current.filter((item) => item.id !== attachment.id)
                          );
                          emitDraftChange();
                        }}
                      >
                        <X size={12} />
                      </button>
                    </div>
                  )}
                </For>
              </div>
            </Show>

                   <div class="relative min-h-[120px]">
              <Show when={props.toast}>
                <div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-gray-6 bg-gray-1/90 px-3 py-2 text-xs text-gray-11 shadow-lg backdrop-blur-md">
                  {props.toast}
                </div>
              </Show>

              <div class="flex flex-col gap-2">
                <div class="flex-1 min-w-0">
                  <Show when={props.isRemoteWorkspace}>
                    <div class="mb-2 text-[10px] uppercase tracking-wider text-gray-8">Remote workspace</div>
                  </Show>

                  <div class="relative">
                    <Show when={!props.prompt.trim() && !attachments().length}>
                      <div class="absolute left-0 top-0 text-gray-6 text-[15px] leading-relaxed pointer-events-none">
                        Ask OpenWork...
                      </div>
                    </Show>
                    <div
                      ref={editorRef}
                      contentEditable={true}
                      role="textbox"
                      aria-multiline="true"
                      onInput={() => {
                        updateMentionQuery();
                        emitDraftChange();
                      }}
                      onKeyDown={handleKeyDown}
                      onKeyUp={updateMentionQuery}
                      onClick={updateMentionQuery}
                      onPaste={handlePaste}
                      class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
                    />

                    <div class="mt-3" ref={props.setAgentPickerRef}>
                      <button
                        type="button"
                        class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
                        onClick={props.onToggleAgentPicker}
                        aria-expanded={props.agentPickerOpen}
                      >
                        <div class="p-1 rounded bg-gray-4 text-gray-10">
                          <AtSign size={14} />
                        </div>
                        <div class="flex flex-col items-start mr-2 min-w-0">
                          <span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[10rem]">
                            {props.agentLabel}
                          </span>
                          <span class="text-[10px] text-gray-10 font-mono leading-none">
                            {props.selectedAgent ? "Agent" : "Default"}
                          </span>
                        </div>
                        <ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
                      </button>

                      <Show when={props.agentPickerOpen}>
                        <div class="absolute left-0 bottom-full mb-2 w-72 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden">
                          <div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
                            Session agent
                          </div>
                          <div class="max-h-64 overflow-auto p-2 space-y-1">
                            <button
                              type="button"
                              class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
                                props.selectedAgent ? "text-gray-11 hover:bg-gray-12/5" : "bg-gray-12/10 text-gray-12"
                              }`}
                              onClick={() => props.onSelectAgent(null)}
                            >
                              <span>Default agent</span>
                              <Show when={!props.selectedAgent}>
                                <span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
                              </Show>
                            </button>
                            <Show
                              when={!props.agentPickerBusy}
                              fallback={<div class="px-3 py-2 text-xs text-gray-9">Loading agents...</div>}
                            >
                              <Show
                                when={props.agentOptions.length}
                                fallback={<div class="px-3 py-2 text-xs text-gray-9">No agents available.</div>}
                              >
                                <For each={props.agentOptions}>
                                  {(agent: Agent) => (
                                    <button
                                      type="button"
                                      class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
                                        props.selectedAgent === agent.name
                                          ? "bg-gray-12/10 text-gray-12"
                                          : "text-gray-11 hover:bg-gray-12/5"
                                      }`}
                                      onClick={() => props.onSelectAgent(agent.name)}
                                    >
                                      <span>{agent.name}</span>
                                      <Show when={props.selectedAgent === agent.name}>
                                        <span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
                                      </Show>
                                    </button>
                                  )}
                                </For>
                              </Show>
                            </Show>
                            <Show when={props.agentPickerError}>
                              <div class="px-3 py-2 text-xs text-red-11">{props.agentPickerError}</div>
                            </Show>
                          </div>
                          <div class="border-t border-gray-6/40 px-4 py-2 text-[10px] text-gray-9">
                            Tip: use /agent-next or /agent-prev to cycle.
                          </div>
                        </div>
                      </Show>
                    </div>

                    <div class="absolute bottom-0 right-0 z-20 flex items-center gap-2">
                      <input
                        ref={fileInputRef}
                        type="file"
                        multiple
                        accept={ACCEPTED_FILE_TYPES.join(",")}
                        class="hidden"
                        onChange={(event: Event) => {
                          const target = event.currentTarget as HTMLInputElement;
                          const files = Array.from(target.files ?? []);
                          if (files.length) void addAttachments(files);
                          target.value = "";
                        }}
                      />
                      <button
                        type="button"
                        class="p-2 rounded-xl border border-gray-6 text-gray-10 hover:text-gray-12 hover:border-gray-7 transition-colors"
                        onClick={() => {
                          if (props.isRemoteWorkspace) {
                            props.onToast("Attachments are unavailable in remote workspaces.");
                            return;
                          }
                          fileInputRef?.click();
                        }}
                        title="Attach files"
                      >
                        <Paperclip size={16} />
                      </button>

                      <button
                        disabled={!props.prompt.trim() && !attachments().length}
                        onClick={sendDraft}
                        class={`p-2 rounded-xl transition-all shadow-lg shrink-0 flex items-center justify-center ${
                          !props.prompt.trim() && !attachments().length
                            ? "bg-gray-4 text-gray-8 cursor-not-allowed"
                            : "bg-gray-12 text-gray-1 hover:scale-105 active:scale-95"
                        }`}
                        title="Run"
                      >
                        <ArrowRight size={18} />
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/session/context-panel.tsx
================================================
import { For, Show } from "solid-js";
import { ChevronDown, Circle, File, Folder, Package } from "lucide-solid";

import { SUGGESTED_PLUGINS } from "../../constants";
import type { McpServerEntry, McpStatus, McpStatusMap, SkillCard } from "../../types";
import { stripPluginVersion } from "../../utils/plugins";

export type ContextPanelProps = {
  activePlugins: string[];
  activePluginStatus: string | null;
  mcpServers: McpServerEntry[];
  mcpStatuses: McpStatusMap;
  mcpStatus: string | null;
  skills: SkillCard[];
  skillsStatus: string | null;
  authorizedDirs: string[];
  workingFiles: string[];
  expandedSections: {
    context: boolean;
    plugins: boolean;
    mcp: boolean;
    skills: boolean;
    authorizedFolders: boolean;
  };
  onToggleSection: (section: "context" | "plugins" | "mcp" | "skills" | "authorizedFolders") => void;
};

const humanizePlugin = (name: string) => {
  const cleaned = name
    .replace(/^@[^/]+\//, "")
    .replace(/[-_]+/g, " ")
    .replace(/\b(opencode|plugin)\b/gi, "")
    .trim();
  return cleaned
    .split(" ")
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ")
    .trim();
};

const matchSuggestedPlugin = (name: string) => {
  const normalized = stripPluginVersion(name).toLowerCase();
  if (!normalized) return null;
  return (
    SUGGESTED_PLUGINS.find((plugin) => {
      const candidates = [plugin.packageName, plugin.name, ...(plugin.aliases ?? [])]
        .map((candidate) => stripPluginVersion(candidate).toLowerCase())
        .filter(Boolean);
      return candidates.includes(normalized);
    }) ?? null
  );
};

const humanizeSkill = (name: string) => {
  const cleaned = name.replace(/^@[^/]+\//, "").replace(/[-_]+/g, " ").trim();
  if (!cleaned) return name;
  return cleaned
    .split(" ")
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ")
    .trim();
};

const mcpStatusLabel = (status?: McpStatus, disabled?: boolean) => {
  if (disabled) return "Disabled";
  if (!status) return "Disconnected";
  switch (status.status) {
    case "connected":
      return "Connected";
    case "needs_auth":
      return "Needs auth";
    case "needs_client_registration":
      return "Register client";
    case "failed":
      return "Failed";
    default:
      return "Disconnected";
  }
};

const mcpStatusDot = (status?: McpStatus, disabled?: boolean) => {
  if (disabled) return "bg-gray-7";
  if (!status) return "bg-gray-7";
  switch (status.status) {
    case "connected":
      return "bg-green-9";
    case "needs_auth":
    case "needs_client_registration":
      return "bg-amber-9";
    case "failed":
      return "bg-red-9";
    default:
      return "bg-gray-7";
  }
};

export default function ContextPanel(props: ContextPanelProps) {
  return (
    <div class="flex flex-col h-full overflow-hidden">
      <div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
        <div class="rounded-2xl border border-gray-6 bg-gray-2/30" id="sidebar-context">
          <button
            class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
            onClick={() => props.onToggleSection("context")}
          >
            <span>Context</span>
            <ChevronDown
              size={16}
              class={`transition-transform text-gray-10 ${props.expandedSections.context ? "rotate-180" : ""}`.trim()}
            />
          </button>
          <Show when={props.expandedSections.context}>
            <div class="px-4 pb-4 pt-1 space-y-5">
              <div>
                <div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
                  <span>Working files</span>
                </div>
                <div class="space-y-2">
                  <Show
                    when={props.workingFiles.length}
                    fallback={<div class="text-xs text-gray-9">None yet.</div>}
                  >
                    <For each={props.workingFiles}>
                      {(file) => (
                        <div class="flex items-center gap-2 text-xs text-gray-11">
                          <File size={12} class="text-gray-9" />
                          <span class="truncate">{file}</span>
                        </div>
                      )}
                    </For>
                  </Show>
                </div>
              </div>
            </div>
          </Show>
        </div>

        <div class="rounded-2xl border border-gray-6 bg-gray-2/30">
          <button
            class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
            onClick={() => props.onToggleSection("plugins")}
          >
            <span>Plugins</span>
            <ChevronDown
              size={16}
              class={`transition-transform text-gray-10 ${props.expandedSections.plugins ? "rotate-180" : ""}`.trim()}
            />
          </button>
          <Show when={props.expandedSections.plugins}>
            <div class="px-4 pb-4 pt-1">
              <div class="space-y-2">
                <Show
                  when={props.activePlugins.length}
                  fallback={
                    <div class="text-xs text-gray-9">
                      {props.activePluginStatus ?? "No plugins loaded."}
                    </div>
                  }
                >
                  <For each={props.activePlugins}>
                    {(plugin) => {
                      const suggested = matchSuggestedPlugin(plugin);
                      const normalized = stripPluginVersion(plugin) || plugin;
                      const label = humanizePlugin(suggested?.name ?? normalized) || normalized;
                      const description = suggested?.description?.trim();
                      const detail = description || (normalized !== label ? normalized : "");
                      return (
                        <div class="flex items-start gap-2 text-xs text-gray-11">
                          <Circle size={6} class="text-green-9 fill-green-9 mt-1" />
                          <div class="min-w-0">
                            <div class="truncate">{label}</div>
                            <Show when={detail}>
                              <div class="text-[11px] text-gray-9 truncate" title={detail}>
                                {detail}
                              </div>
                            </Show>
                          </div>
                        </div>
                      );
                    }}
                  </For>
                </Show>
              </div>
            </div>
          </Show>
        </div>

        <div class="rounded-2xl border border-gray-6 bg-gray-2/30">
          <button
            class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
            onClick={() => props.onToggleSection("mcp")}
          >
            <span>MCP</span>
            <ChevronDown
              size={16}
              class={`transition-transform text-gray-10 ${props.expandedSections.mcp ? "rotate-180" : ""}`.trim()}
            />
          </button>
          <Show when={props.expandedSections.mcp}>
            <div class="px-4 pb-4 pt-1">
              <div class="space-y-2">
                <Show
                  when={props.mcpServers.length}
                  fallback={
                    <div class="text-xs text-gray-9">
                      {props.mcpStatus ?? "No MCP servers loaded."}
                    </div>
                  }
                >
                  <For each={props.mcpServers}>
                    {(entry) => {
                      const status = () => props.mcpStatuses[entry.name];
                      const disabled = () => entry.config.enabled === false;
                      const detail =
                        entry.config.type === "remote"
                          ? entry.config.url
                          : entry.config.command?.join(" ");
                      return (
                        <div class="flex items-start gap-2 text-xs text-gray-11">
                          <span class={`mt-1 h-2 w-2 rounded-full ${mcpStatusDot(status(), disabled())}`} />
                          <div class="min-w-0">
                            <div class="truncate">{entry.name}</div>
                            <div class="text-[11px] text-gray-9 truncate">
                              {mcpStatusLabel(status(), disabled())}
                              {detail ? ` - ${detail}` : ""}
                            </div>
                          </div>
                        </div>
                      );
                    }}
                  </For>
                </Show>
              </div>
            </div>
          </Show>
        </div>

        <div class="rounded-2xl border border-gray-6 bg-gray-2/30">
          <button
            class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
            onClick={() => props.onToggleSection("skills")}
          >
            <span>Skills</span>
            <ChevronDown
              size={16}
              class={`transition-transform text-gray-10 ${props.expandedSections.skills ? "rotate-180" : ""}`.trim()}
            />
          </button>
          <Show when={props.expandedSections.skills}>
            <div class="px-4 pb-4 pt-1">
              <div class="space-y-2">
                <Show
                  when={props.skills.length}
                  fallback={
                    <div class="text-xs text-gray-9">
                      {props.skillsStatus ?? "No skills loaded."}
                    </div>
                  }
                >
                  <For each={props.skills}>
                    {(skill) => {
                      const label = humanizeSkill(skill.name) || skill.name;
                      const description = skill.description?.trim();
                      return (
                        <div class="flex items-start gap-2 text-xs text-gray-11">
                          <Package size={12} class="text-gray-9 mt-0.5" />
                          <div class="min-w-0">
                            <div class="truncate">{label}</div>
                            <Show when={description}>
                              <div class="text-[11px] text-gray-9 truncate" title={description}>
                                {description}
                              </div>
                            </Show>
                          </div>
                        </div>
                      );
                    }}
                  </For>
                </Show>
              </div>
            </div>
          </Show>
        </div>

        <div class="rounded-2xl border border-gray-6 bg-gray-2/30">
          <button
            class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
            onClick={() => props.onToggleSection("authorizedFolders")}
          >
            <span>Authorized folders</span>
            <ChevronDown
              size={16}
              class={`transition-transform text-gray-10 ${
                props.expandedSections.authorizedFolders ? "rotate-180" : ""
              }`.trim()}
            />
          </button>
          <Show when={props.expandedSections.authorizedFolders}>
            <div class="px-4 pb-4 pt-1">
              <div class="space-y-2">
                <Show
                  when={props.authorizedDirs.length}
                  fallback={<div class="text-xs text-gray-9">None yet.</div>}
                >
                  <For each={props.authorizedDirs.slice(0, 3)}>
                    {(folder) => (
                      <div class="flex items-center gap-2 text-xs text-gray-11">
                        <Folder size={12} class="text-gray-9" />
                        <span class="truncate" title={folder}>
                          {folder.split(/[/\\]/).pop()}
                        </span>
                      </div>
                    )}
                  </For>
                </Show>
              </div>
            </div>
          </Show>
        </div>
      </div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/session/message-list.tsx
================================================
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js";
import type { JSX } from "solid-js";
import type { Part } from "@opencode-ai/sdk/v2/client";
import { Check, ChevronDown, Circle, Copy, File, Sparkles } from "lucide-solid";

import type { MessageGroup, MessageWithParts } from "../../types";
import { groupMessageParts, summarizeStep } from "../../utils";
import PartView from "../part-view";

export type MessageListProps = {
  messages: MessageWithParts[];
  developerMode: boolean;
  showThinking: boolean;
  expandedStepIds: Set<string>;
  setExpandedStepIds: (updater: (current: Set<string>) => Set<string>) => void;
  footer?: JSX.Element;
};

type StepClusterBlock = {
  kind: "steps-cluster";
  id: string;
  stepIds: string[];
  partsGroups: Part[][];
  messageIds: string[];
  isUser: boolean;
};

type MessageBlock = {
  kind: "message";
  message: MessageWithParts;
  renderableParts: Part[];
  groups: MessageGroup[];
  isUser: boolean;
  messageId: string;
};

type MessageBlockItem = MessageBlock | StepClusterBlock;

export default function MessageList(props: MessageListProps) {
  const [copyingId, setCopyingId] = createSignal<string | null>(null);
  let copyTimeout: number | undefined;
  const isAttachmentPart = (part: Part) => {
    if (part.type !== "file") return false;
    const url = (part as { url?: string }).url;
    return typeof url === "string" && !url.startsWith("file://");
  };
  const attachmentsForMessage = (message: MessageWithParts) =>
    message.parts
      .filter(isAttachmentPart)
      .map((part) => {
        const record = part as { url?: string; filename?: string; mime?: string };
        return {
          url: record.url ?? "",
          filename: record.filename ?? "attachment",
          mime: record.mime ?? "application/octet-stream",
        };
      })
      .filter((attachment) => !!attachment.url);
  const isImageAttachment = (mime: string) => mime.startsWith("image/");

  onCleanup(() => {
    if (copyTimeout !== undefined) {
      window.clearTimeout(copyTimeout);
    }
  });

  const handleCopy = async (text: string, id: string) => {
    try {
      await navigator.clipboard.writeText(text);
      setCopyingId(id);
      if (copyTimeout !== undefined) {
        window.clearTimeout(copyTimeout);
      }
      copyTimeout = window.setTimeout(() => {
        setCopyingId(null);
        copyTimeout = undefined;
      }, 2000);
    } catch {
      // ignore
    }
  };

  const partToText = (part: Part) => {
    if (part.type === "text") {
      return String((part as { text?: string }).text ?? "");
    }
    if (part.type === "agent") {
      const name = (part as { name?: string }).name ?? "";
      return name ? `@${name}` : "@agent";
    }
    if (part.type === "file") {
      const record = part as { label?: string; path?: string; filename?: string };
      const label = record.label ?? record.path ?? record.filename ?? "";
      return label ? `@${label}` : "@file";
    }
    return "";
  };

  // Note: expandedStepIds now tracks COLLAPSED steps (inverted logic for default-expanded behavior)
  const toggleSteps = (id: string, relatedIds: string[] = []) => {
    props.setExpandedStepIds((current) => {
      const next = new Set(current);
      // Inverted: if in set = collapsed, so check if collapsed to expand (remove from set)
      const isCollapsed = next.has(id) || relatedIds.some((relatedId) => next.has(relatedId));
      if (isCollapsed) {
        // Currently collapsed -> expand by removing from set
        next.delete(id);
        relatedIds.forEach((relatedId) => next.delete(relatedId));
      } else {
        // Currently expanded -> collapse by adding to set
        next.add(id);
        relatedIds.forEach((relatedId) => next.delete(relatedId));
      }
      return next;
    });
  };

  // Inverted: steps are expanded by default (when NOT in the set)
  const isStepsExpanded = (id: string, relatedIds: string[] = []) =>
    !props.expandedStepIds.has(id) &&
    !relatedIds.some((relatedId) => props.expandedStepIds.has(relatedId));

  const renderablePartsForMessage = (message: MessageWithParts) =>
    message.parts.filter((part) => {
      if (part.type === "reasoning") {
        return props.developerMode && props.showThinking;
      }

      if (part.type === "step-start" || part.type === "step-finish") {
        return props.developerMode;
      }

      if (part.type === "text" || part.type === "tool" || part.type === "agent" || part.type === "file") {
        return true;
      }

      return props.developerMode;
    });

  const messageBlocks = createMemo<MessageBlockItem[]>(() => {
    const blocks: MessageBlockItem[] = [];

    for (const message of props.messages) {
      const renderableParts = renderablePartsForMessage(message);
      if (!renderableParts.length) continue;

      const messageId = String((message.info as any).id ?? "");
      const groupId = String((message.info as any).id ?? "message");
      const groups = groupMessageParts(renderableParts, groupId);
      const isUser = (message.info as any).role === "user";
      const isStepsOnly = groups.length === 1 && groups[0].kind === "steps";

      if (isStepsOnly) {
        const stepGroup = groups[0] as { kind: "steps"; id: string; parts: Part[] };
        const lastBlock = blocks[blocks.length - 1];
        if (lastBlock && lastBlock.kind === "steps-cluster" && lastBlock.isUser === isUser) {
          lastBlock.partsGroups.push(stepGroup.parts);
          lastBlock.stepIds.push(stepGroup.id);
          lastBlock.messageIds.push(messageId);
        } else {
          blocks.push({
            kind: "steps-cluster",
            id: stepGroup.id,
            stepIds: [stepGroup.id],
            partsGroups: [stepGroup.parts],
            messageIds: [messageId],
            isUser,
          });
        }
        continue;
      }

      blocks.push({
        kind: "message",
        message,
        renderableParts,
        groups,
        isUser,
        messageId,
      });
    }

    return blocks;
  });

  const StepsList = (listProps: { parts: Part[]; isUser: boolean }) => (
    <div class="space-y-3">
      <For each={listProps.parts}>
        {(part) => {
          const summary = summarizeStep(part);
          return (
            <div class="flex items-start gap-3 text-xs text-gray-11">
              <div class={`mt-0.5 h-5 w-5 rounded-full border flex items-center justify-center ${
                summary.isSkill 
                  ? "border-purple-7 bg-purple-3 text-purple-10" 
                  : "border-gray-7 text-gray-10"
              }`}>
                {summary.isSkill ? <Sparkles size={12} /> : part.type === "tool" ? <File size={12} /> : <Circle size={8} />}
              </div>
              <div class="flex-1">
                <div class="flex items-center gap-2">
                  <span class="text-gray-12">{summary.title}</span>
                  <Show when={summary.isSkill}>
                    <span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
                      skill
                    </span>
                  </Show>
                </div>
                <Show when={summary.detail}>
                  <div class="mt-1 text-gray-10">{summary.detail}</div>
                </Show>
                <Show when={props.developerMode && (part.type !== "tool" || props.showThinking)}>
                  <div class="mt-2 text-xs text-gray-10">
                    <PartView
                      part={part}
                      developerMode={props.developerMode}
                      showThinking={props.showThinking}
                      tone={listProps.isUser ? "dark" : "light"}
                    />
                  </div>
                </Show>
              </div>
            </div>
          );
        }}
      </For>
    </div>
  );

  return (
    <div class="max-w-3xl mx-auto space-y-6 pb-32 px-4">
      <For each={messageBlocks()}>
        {(block) => {
          if (block.kind === "steps-cluster") {
            const relatedStepIds = block.stepIds.filter((stepId) => stepId !== block.id);
            const expanded = () => isStepsExpanded(block.id, relatedStepIds);
            return (
              <div
                class={`flex group ${block.isUser ? "justify-end" : "justify-start"}`.trim()}
                data-message-role={block.isUser ? "user" : "assistant"}
                data-message-id={block.messageIds[0] ?? ""}
              >
                <div
                  class={`w-full relative ${
                    block.isUser
                      ? "max-w-2xl px-6 py-4 rounded-[24px] bg-gray-3 text-gray-12 text-[15px] leading-relaxed"
                      : "max-w-[68ch] text-[15px] leading-7 text-gray-12 group pl-2"
                  }`}
                >
                  <div class={block.isUser ? "mt-2" : "mt-3 border-t border-gray-6/60 pt-3"}>
                    <button
                      class={`flex items-center gap-2 text-xs ${
                        block.isUser ? "text-gray-10 hover:text-gray-11" : "text-gray-10 hover:text-gray-12"
                      }`}
                      onClick={() => toggleSteps(block.id, relatedStepIds)}
                    >
                      <span>{expanded() ? "Hide steps" : "View steps"}</span>
                      <ChevronDown
                        size={14}
                        class={`transition-transform ${expanded() ? "rotate-180" : ""}`.trim()}
                      />
                    </button>
                    <Show when={expanded()}>
                      <div
                        class={`mt-3 rounded-xl border p-3 max-h-96 overflow-auto ${
                          block.isUser
                            ? "border-gray-6 bg-gray-1/60"
                            : "border-gray-6/70 bg-gray-2/40"
                        }`}
                      >
                        <For each={block.partsGroups}>
                          {(parts, index) => (
                            <div
                              class={
                                index() === 0
                                  ? ""
                                  : "mt-3 pt-3 border-t border-gray-6/60"
                              }
                            >
                              <StepsList parts={parts} isUser={block.isUser} />
                            </div>
                          )}
                        </For>
                      </div>
                    </Show>
                  </div>
                </div>
              </div>
            );
          }

          const groupSpacing = block.isUser ? "mb-3" : "mb-4";
          return (
            <div
              class={`flex group ${block.isUser ? "justify-end" : "justify-start"}`.trim()}
              data-message-role={block.isUser ? "user" : "assistant"}
              data-message-id={block.messageId}
            >
              <div
                class={`w-full relative ${
                  block.isUser
                    ? "max-w-2xl px-6 py-4 rounded-[24px] bg-gray-3 text-gray-12 text-[15px] leading-relaxed"
                    : "max-w-[68ch] text-[15px] leading-7 text-gray-12 group pl-2"
                }`}
              >
                <Show when={attachmentsForMessage(block.message).length > 0}>
                  <div class={block.isUser ? "mb-3 flex flex-wrap gap-2" : "mb-4 flex flex-wrap gap-2"}>
                    <For each={attachmentsForMessage(block.message)}>
                      {(attachment) => (
                        <div class="flex items-center gap-2 rounded-2xl border border-gray-6 bg-gray-1/70 px-3 py-2 text-xs text-gray-11">
                          <Show
                            when={isImageAttachment(attachment.mime)}
                            fallback={<File size={14} class="text-gray-9" />}
                          >
                            <div class="h-12 w-12 rounded-xl bg-gray-2 overflow-hidden border border-gray-6">
                              <img
                                src={attachment.url}
                                alt={attachment.filename}
                                class="h-full w-full object-cover"
                              />
                            </div>
                          </Show>
                          <div class="max-w-[180px]">
                            <div class="truncate text-gray-12">{attachment.filename}</div>
                            <div class="text-[10px] text-gray-9">{attachment.mime}</div>
                          </div>
                        </div>
                      )}
                    </For>
                  </div>
                </Show>
                <For each={block.groups}>
                  {(group, idx) => (
                    <div class={idx() === block.groups.length - 1 ? "" : groupSpacing}>
                      <Show when={group.kind === "text"}>
                        <PartView
                          part={(group as { kind: "text"; part: Part }).part}
                          developerMode={props.developerMode}
                          showThinking={props.showThinking}
                          tone={block.isUser ? "dark" : "light"}
                          renderMarkdown={!block.isUser}
                        />
                      </Show>
                      {group.kind === "steps" &&
                        (() => {
                          const stepGroup = group as { kind: "steps"; id: string; parts: Part[] };
                          const expanded = () => isStepsExpanded(stepGroup.id);
                          return (
                            <div class={block.isUser ? "mt-2" : "mt-3 border-t border-gray-6/60 pt-3"}>
                              <button
                                class={`flex items-center gap-2 text-xs ${
                                  block.isUser
                                    ? "text-gray-10 hover:text-gray-11"
                                    : "text-gray-10 hover:text-gray-12"
                                }`}
                                onClick={() => toggleSteps(stepGroup.id)}
                              >
                                <span>{expanded() ? "Hide steps" : "View steps"}</span>
                                <ChevronDown
                                  size={14}
                                  class={`transition-transform ${expanded() ? "rotate-180" : ""}`.trim()}
                                />
                              </button>
                              <Show when={expanded()}>
                                <div
                                  class={`mt-3 rounded-xl border p-3 max-h-96 overflow-auto ${
                                    block.isUser
                                      ? "border-gray-6 bg-gray-1/60"
                                      : "border-gray-6/70 bg-gray-2/40"
                                  }`}
                                >
                                  <StepsList parts={stepGroup.parts} isUser={block.isUser} />
                                </div>
                              </Show>
                            </div>
                          );
                        })()}
                    </div>
                  )}
                </For>
                <div class="absolute bottom-2 right-2 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity select-none pointer-events-none group-hover:pointer-events-auto">
                  <button
                    class="text-gray-9 hover:text-gray-11 p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
                    title="Copy message"
                    onClick={() => {
                      const text = block.renderableParts
                        .map((part) => partToText(part))
                        .join("\n");
                      handleCopy(text, block.messageId);
                    }}
                  >
                    <Show when={copyingId() === block.messageId} fallback={<Copy size={12} />}>
                      <Check size={12} class="text-green-10" />
                    </Show>
                  </button>
                </div>
              </div>
            </div>
          );
        }}
      </For>
      <Show when={props.footer}>{props.footer}</Show>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/session/minimap.tsx
================================================
import { For, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import type { MessageWithParts } from "../../types";

export type MinimapProps = {
  containerRef: () => HTMLDivElement | undefined;
  messages: MessageWithParts[];
};

export default function Minimap(props: MinimapProps) {
  const [lines, setLines] = createSignal<{ id: string; role: "user" | "assistant"; top: number; height: number }[]>([]);
  const [activeId, setActiveId] = createSignal<string | null>(null);

  let rafId: number | null = null;

  const update = () => {
    const container = props.containerRef();
    if (!container) return;

    const containerRect = container.getBoundingClientRect();
    const scrollTop = container.scrollTop;
    
    // Find all message groups (bubbles)
    // We assume MessageList renders them with data-message-id
    const elements = Array.from(container.querySelectorAll('[data-message-role]'));
    
    const nextLines = elements.map(el => {
      const rect = el.getBoundingClientRect();
      const relativeTop = rect.top - containerRect.top + scrollTop;
      const scrollHeight = container.scrollHeight;
      const clientHeight = container.clientHeight;
      
      // Map content position (0 to scrollHeight) to viewport position (0 to clientHeight)
      const mapTop = (relativeTop / scrollHeight) * clientHeight;
      
      return {
        id: el.getAttribute('data-message-id') || "",
        role: el.getAttribute('data-message-role') as "user" | "assistant",
        top: mapTop,
        height: 2 
      };
    });
    
    setLines(nextLines);

    // Update active message based on center
    const center = containerRect.top + containerRect.height / 2;
    let closestId = null;
    let minDist = Infinity;
    
    elements.forEach(el => {
      const rect = el.getBoundingClientRect();
      const dist = Math.abs((rect.top + rect.height / 2) - center);
      if (dist < minDist) {
        minDist = dist;
        closestId = el.getAttribute('data-message-id');
      }
    });
    setActiveId(closestId);
  };

  const scheduleUpdate = () => {
    if (rafId !== null) cancelAnimationFrame(rafId);
    rafId = requestAnimationFrame(() => {
      update();
      rafId = null;
    });
  };

  createEffect(() => {
    props.messages.length;
    scheduleUpdate();
  });

  createEffect(() => {
    const container = props.containerRef();
    if (!container) return;

    container.addEventListener("scroll", scheduleUpdate);
    window.addEventListener("resize", scheduleUpdate);

    onCleanup(() => {
      container.removeEventListener("scroll", scheduleUpdate);
      window.removeEventListener("resize", scheduleUpdate);
      if (rafId !== null) cancelAnimationFrame(rafId);
    });
  });

  return (
    <div class="hidden lg:flex w-6 bg-gray-1/50 border-l border-gray-3 flex-col items-center justify-start relative group/rail z-10 overflow-hidden py-2 h-full backdrop-blur-sm">
      <For each={lines()}>
        {(line, idx) => {
          const isActive = () => line.id === activeId();
          const isUser = line.role === "user";
          
          return (
            <button
              type="button"
              aria-label={`${isUser ? "User" : "Agent"} message ${idx() + 1}`}
              aria-current={isActive() ? "true" : undefined}
              class={`absolute left-1/2 -translate-x-1/2 rounded-full transition-all duration-300 ease-out cursor-pointer appearance-none border-none p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-12/70
                ${
                  isActive()
                    ? "w-4 h-1.5 opacity-100 z-20 bg-gray-12 shadow-sm"
                    : `w-2 h-1 ${isUser ? "bg-gray-11 opacity-60" : "bg-gray-6 opacity-40"} hover:w-3 hover:opacity-100`
                }
              `}
              style={{
                top: `${line.top}px`,
              }}
              title={isUser ? "User" : "Agent"}
              onClick={(e) => {
                e.stopPropagation();
                const container = props.containerRef();
                const el = container?.querySelector(`[data-message-id="${line.id}"]`);
                el?.scrollIntoView({ behavior: "smooth", block: "center" });
              }}
            >
              <span aria-hidden="true" class="absolute -inset-x-2 -inset-y-1" />
            </button>
          );
        }}
      </For>
    </div>
  );
}



================================================
FILE: packages/app/src/app/components/session/sidebar.tsx
================================================
import { For, Show, createMemo } from "solid-js";
import { Check, ChevronDown, Plus } from "lucide-solid";

import type { TodoItem } from "../../types";

export type SidebarSectionState = {
  progress: boolean;
  artifacts: boolean;
  context: boolean;
  plugins: boolean;
  mcp: boolean;
  skills: boolean;
  authorizedFolders: boolean;
};

export type SidebarProps = {
  todos: TodoItem[];
  expandedSections: SidebarSectionState;
  onToggleSection: (section: keyof SidebarSectionState) => void;
  sessions: Array<{ id: string; title: string; slug?: string | null; workspaceLabel?: string | null }>;
  selectedSessionId: string | null;
  onSelectSession: (id: string) => void;
  sessionStatusById: Record<string, string>;
  onCreateSession: () => void;
  newTaskDisabled: boolean;
};

export default function SessionSidebar(props: SidebarProps) {
  const realTodos = createMemo(() => props.todos.filter((todo) => todo.content.trim()));

  const progressDots = createMemo(() => {
    const activeTodos = realTodos();
    const total = activeTodos.length;
    if (!total) return [] as boolean[];
    const completed = activeTodos.filter((todo) => todo.status === "completed").length;
    return Array.from({ length: total }, (_, idx) => idx < completed);
  });

  return (
    <div class="flex flex-col h-full overflow-hidden">
      <div class="px-4 pt-4 shrink-0">
        <button
          class="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl bg-gray-12 text-gray-1 text-sm font-medium shadow-lg shadow-gray-12/10 hover:bg-gray-11 transition-colors"
          onClick={props.onCreateSession}
          disabled={props.newTaskDisabled}
        >
          <Plus size={16} />
          New task
        </button>
      </div>

      <div class="flex-1 overflow-y-auto px-4 py-4 space-y-6">
        <div>
          <div class="text-[10px] text-gray-9 uppercase tracking-widest font-semibold mb-3 px-2">Recents</div>
          <div class="space-y-1">
            <For each={props.sessions.slice(0, 8)}>
              {(session) => (
                <button
                  class={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
                    session.id === props.selectedSessionId
                      ? "bg-gray-3 text-gray-12 font-medium"
                      : "text-gray-11 hover:text-gray-12 hover:bg-gray-2"
                  }`}
                  onClick={() => props.onSelectSession(session.id)}
                >
                    <div class="flex items-center justify-between gap-2 w-full overflow-hidden">
                      <div class="min-w-0 flex items-center gap-2">
                        <div class="truncate">{session.title}</div>
                        <Show when={session.workspaceLabel}>
                          <div class="shrink-0 text-[9px] font-medium uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-10 border border-gray-6/50">
                            {session.workspaceLabel}
                          </div>
                        </Show>
                      </div>
                      <Show
                        when={
                          props.sessionStatusById[session.id] &&
                        props.sessionStatusById[session.id] !== "idle"
                      }
                    >
                      <span
                        class={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full border flex items-center gap-1 ${
                          props.sessionStatusById[session.id] === "running"
                            ? "border-amber-7/50 text-amber-11 bg-amber-2/50"
                            : "border-gray-7/50 text-gray-10 bg-gray-2/50"
                        }`}
                      >
                        <div
                          class={`w-1 h-1 rounded-full ${
                            props.sessionStatusById[session.id] === "running"
                              ? "bg-amber-9 animate-pulse"
                              : "bg-gray-9"
                          }`}
                        />
                      </span>
                    </Show>
                  </div>
                </button>
              )}
            </For>
          </div>
        </div>

        <div class="space-y-4">
          <Show when={realTodos().length > 0}>
            <div class="rounded-2xl border border-gray-6 bg-gray-2/30" id="sidebar-progress">
              <button
                class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
                onClick={() => props.onToggleSection("progress")}
              >
                <span>Progress</span>
                <ChevronDown
                  size={16}
                  class={`transition-transform text-gray-10 ${
                    props.expandedSections.progress ? "rotate-180" : ""
                  }`.trim()}
                />
              </button>
              <Show when={props.expandedSections.progress}>
                <div class="px-4 pb-4 pt-1">
                  <div class="flex items-center gap-2 flex-wrap">
                    <For each={progressDots()}>
                      {(done) => (
                        <div
                          class={`h-6 w-6 rounded-full border flex items-center justify-center transition-colors ${
                            done
                              ? "border-green-6 bg-green-2 text-green-11"
                              : "border-gray-6 bg-gray-1 text-gray-8"
                          }`}
                        >
                          <Show when={done}>
                            <Check size={14} />
                          </Show>
                        </div>
                      )}
                    </For>
                  </div>
                </div>
              </Show>
            </div>
          </Show>
        </div>
      </div>
    </div>
  );
}



================================================
FILE: packages/app/src/app/context/extensions.ts
================================================
import { createSignal } from "solid-js";

import { applyEdits, modify } from "jsonc-parser";
import { join } from "@tauri-apps/api/path";
import { currentLocale, t } from "../../i18n";

import type { Client, Mode, PluginScope, ReloadReason, SkillCard } from "../types";
import { addOpencodeCacheHint, isTauriRuntime } from "../utils";
import skillCreatorTemplate from "../data/skill-creator.md?raw";
import {
  isPluginInstalled,
  loadPluginsFromConfig as loadPluginsFromConfigHelpers,
  parsePluginListFromContent,
  stripPluginVersion,
} from "../utils/plugins";
import {
  importSkill,
  installSkillTemplate,
  listLocalSkills,
  uninstallSkill as uninstallSkillCommand,
  pickDirectory,
  readOpencodeConfig,
  writeOpencodeConfig,
  type OpencodeConfigFile,
} from "../lib/tauri";
import type {
  OpenworkServerCapabilities,
  OpenworkServerClient,
  OpenworkServerStatus,
} from "../lib/openwork-server";

export type ExtensionsStore = ReturnType<typeof createExtensionsStore>;

export function createExtensionsStore(options: {
  client: () => Client | null;
  mode: () => Mode | null;
  projectDir: () => string;
  activeWorkspaceRoot: () => string;
  workspaceType: () => "local" | "remote";
  openworkServerClient: () => OpenworkServerClient | null;
  openworkServerStatus: () => OpenworkServerStatus;
  openworkServerCapabilities: () => OpenworkServerCapabilities | null;
  openworkServerWorkspaceId: () => string | null;
  setBusy: (value: boolean) => void;
  setBusyLabel: (value: string | null) => void;
  setBusyStartedAt: (value: number | null) => void;
  setError: (value: string | null) => void;
  markReloadRequired: (reason: ReloadReason) => void;
  onNotionSkillInstalled?: () => void;
}) {
  // Translation helper that uses current language from i18n
  const translate = (key: string) => t(key, currentLocale());

  const [skills, setSkills] = createSignal<SkillCard[]>([]);
  const [skillsStatus, setSkillsStatus] = createSignal<string | null>(null);

  const formatSkillPath = (location: string) => location.replace(/[/\\]SKILL\.md$/i, "");

  const [pluginScope, setPluginScope] = createSignal<PluginScope>("project");
  const [pluginConfig, setPluginConfig] = createSignal<OpencodeConfigFile | null>(null);
  const [pluginConfigPath, setPluginConfigPath] = createSignal<string | null>(null);
  const [pluginList, setPluginList] = createSignal<string[]>([]);
  const [pluginInput, setPluginInput] = createSignal("");
  const [pluginStatus, setPluginStatus] = createSignal<string | null>(null);
  const [activePluginGuide, setActivePluginGuide] = createSignal<string | null>(null);

  const [sidebarPluginList, setSidebarPluginList] = createSignal<string[]>([]);
  const [sidebarPluginStatus, setSidebarPluginStatus] = createSignal<string | null>(null);

  // Track in-flight requests to prevent duplicate calls
  let refreshSkillsInFlight = false;
  let refreshPluginsInFlight = false;
  let refreshSkillsAborted = false;
  let refreshPluginsAborted = false;
  let skillsLoaded = false;
  let skillsRoot = "";

  const isPluginInstalledByName = (pluginName: string, aliases: string[] = []) =>
    isPluginInstalled(pluginList(), pluginName, aliases);

  const loadPluginsFromConfig = (config: OpencodeConfigFile | null) => {
    loadPluginsFromConfigHelpers(config, setPluginList, (message) => setPluginStatus(message));
  };

  async function refreshSkills(optionsOverride?: { force?: boolean }) {
    const root = options.activeWorkspaceRoot().trim();
    const isRemoteWorkspace = options.workspaceType() === "remote";
    const isHostMode = options.mode() === "host";
    const openworkClient = options.openworkServerClient();
    const openworkWorkspaceId = options.openworkServerWorkspaceId();
    const openworkCapabilities = options.openworkServerCapabilities();
    const canUseOpenworkServer =
      options.openworkServerStatus() === "connected" &&
      openworkClient &&
      openworkWorkspaceId &&
      openworkCapabilities?.skills?.read;

    if (!root) {
      setSkills([]);
      setSkillsStatus(translate("skills.pick_workspace_first"));
      return;
    }

    // Host mode or remote workspace: prefer OpenWork server when available
    if ((isRemoteWorkspace || isHostMode) && canUseOpenworkServer) {
      if (root !== skillsRoot) {
        skillsLoaded = false;
      }

      if (!optionsOverride?.force && skillsLoaded) {
        return;
      }

      if (refreshSkillsInFlight) {
        return;
      }

      refreshSkillsInFlight = true;
      refreshSkillsAborted = false;

      try {
        setSkillsStatus(null);
        const response = await openworkClient.listSkills(openworkWorkspaceId, {
          includeGlobal: isHostMode,
        });
        if (refreshSkillsAborted) return;
        const next: SkillCard[] = Array.isArray(response.items)
          ? response.items.map((entry) => ({
              name: entry.name,
              description: entry.description,
              path: entry.path,
            }))
          : [];
        setSkills(next);
        if (!next.length) {
          setSkillsStatus(translate("skills.no_skills_found"));
        }
        skillsLoaded = true;
        skillsRoot = root;
      } catch (e) {
        if (refreshSkillsAborted) return;
        setSkills([]);
        setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load"));
      } finally {
        refreshSkillsInFlight = false;
      }

      return;
    }

    // Host/Tauri mode fallback: read directly from `.opencode/skills` or `.claude/skills`
    // so the UI still works even if the OpenCode engine is stopped or unreachable.
    if (isHostMode && isTauriRuntime()) {
      if (root !== skillsRoot) {
        skillsLoaded = false;
      }

      if (!optionsOverride?.force && skillsLoaded) {
        return;
      }

      if (refreshSkillsInFlight) {
        return;
      }

      refreshSkillsInFlight = true;
      refreshSkillsAborted = false;

      try {
        setSkillsStatus(null);
        const local = await listLocalSkills(root);
        if (refreshSkillsAborted) return;

        const next: SkillCard[] = Array.isArray(local)
          ? local.map((entry) => ({
              name: entry.name,
              description: entry.description,
              path: entry.path,
            }))
          : [];

        setSkills(next);
        if (!next.length) {
          setSkillsStatus(translate("skills.no_skills_found"));
        }
        skillsLoaded = true;
        skillsRoot = root;
      } catch (e) {
        if (refreshSkillsAborted) return;
        setSkills([]);
        setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load"));
      } finally {
        refreshSkillsInFlight = false;
      }

      return;
    }

    const c = options.client();
    if (!c) {
      setSkills([]);
      setSkillsStatus(
        isRemoteWorkspace
          ? "OpenWork server unavailable. Connect to load skills."
          : translate("skills.connect_host_to_load"),
      );
      return;
    }

    if (root !== skillsRoot) {
      skillsLoaded = false;
    }

    if (!optionsOverride?.force && skillsLoaded) {
      return;
    }

    if (refreshSkillsInFlight) {
      return;
    }

    refreshSkillsInFlight = true;
    refreshSkillsAborted = false;

    try {
      setSkillsStatus(null);

      if (refreshSkillsAborted) return;

      const rawClient = c as unknown as { _client?: { get: (input: { url: string }) => Promise<any> } };
      if (!rawClient._client) {
        throw new Error("OpenCode client unavailable.");
      }

      const result = await rawClient._client.get({ url: "/skill" });
      if (result?.data === undefined) {
        const err = result?.error;
        const message =
          err instanceof Error ? err.message : typeof err === "string" ? err : translate("skills.failed_to_load");
        throw new Error(message);
      }
      const data = result.data as Array<{
        name: string;
        description: string;
        location: string;
      }>;

      if (refreshSkillsAborted) return;

      const next: SkillCard[] = Array.isArray(data)
        ? data.map((entry) => ({
            name: entry.name,
            description: entry.description,
            path: formatSkillPath(entry.location),
          }))
        : [];

      setSkills(next);
      if (!next.length) {
        setSkillsStatus(translate("skills.no_skills_found"));
      }
      skillsLoaded = true;
      skillsRoot = root;
    } catch (e) {
      if (refreshSkillsAborted) return;
      setSkills([]);
      setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load"));
    } finally {
      refreshSkillsInFlight = false;
    }
  }

  async function refreshPlugins(scopeOverride?: PluginScope) {
    const isRemoteWorkspace = options.workspaceType() === "remote";
    const isHostMode = options.mode() === "host";
    const openworkClient = options.openworkServerClient();
    const openworkWorkspaceId = options.openworkServerWorkspaceId();
    const openworkCapabilities = options.openworkServerCapabilities();
    const canUseOpenworkServer =
      options.openworkServerStatus() === "connected" &&
      openworkClient &&
      openworkWorkspaceId &&
      openworkCapabilities?.plugins?.read;

    // Skip if already in flight
    if (refreshPluginsInFlight) {
      return;
    }

    refreshPluginsInFlight = true;
    refreshPluginsAborted = false;

    const scope = scopeOverride ?? pluginScope();
    const targetDir = options.projectDir().trim();

    // Remote workspace: use OpenWork server
    if (isRemoteWorkspace) {
      setPluginConfig(null);
      setPluginConfigPath("opencode.json (remote)");
      if (scope !== "project") {
        setPluginStatus("Global plugins are only available in Host mode.");
        setPluginList([]);
        setSidebarPluginStatus("Switch to project scope to view remote plugins.");
        setSidebarPluginList([]);
        refreshPluginsInFlight = false;
        return;
      }

      if (!canUseOpenworkServer) {
        setPluginStatus("OpenWork server unavailable. Plugins are read-only.");
        setPluginList([]);
        setSidebarPluginStatus("Connect OpenWork server to load plugins.");
        setSidebarPluginList([]);
        refreshPluginsInFlight = false;
        return;
      }

      try {
        setPluginStatus(null);
        setSidebarPluginStatus(null);

        const result = await openworkClient.listPlugins(openworkWorkspaceId);
        if (refreshPluginsAborted) return;

        const configItems = result.items.filter((item) => item.source === "config");
        const list = configItems.map((item) => item.spec);
        setPluginList(list);
        setSidebarPluginList(list);

        if (!list.length) {
          setPluginStatus("No plugins configured yet.");
        }
      } catch (e) {
        if (refreshPluginsAborted) return;
        setPluginList([]);
        setSidebarPluginStatus("Failed to load plugins.");
        setSidebarPluginList([]);
        setPluginStatus(e instanceof Error ? e.message : "Failed to load plugins.");
      } finally {
        refreshPluginsInFlight = false;
      }

      return;
    }

    // Host mode with OpenWork server: prefer server for project scope plugins
    if (isHostMode && scope === "project" && canUseOpenworkServer) {
      setPluginConfig(null);
      setPluginConfigPath("opencode.json (openwork server)");

      try {
        setPluginStatus(null);
        setSidebarPluginStatus(null);

        if (refreshPluginsAborted) return;

        const result = await openworkClient.listPlugins(openworkWorkspaceId);
        if (refreshPluginsAborted) return;

        const configItems = result.items.filter((item) => item.source === "config");
        const list = configItems.map((item) => item.spec);
        setPluginList(list);
        setSidebarPluginList(list);

        if (!list.length) {
          setPluginStatus("No plugins configured yet.");
        }
      } catch (e) {
        if (refreshPluginsAborted) return;
        setPluginList([]);
        setSidebarPluginStatus("Failed to load plugins.");
        setSidebarPluginList([]);
        setPluginStatus(e instanceof Error ? e.message : "Failed to load plugins.");
      } finally {
        refreshPluginsInFlight = false;
      }

      return;
    }

    if (!isTauriRuntime()) {
      setPluginStatus(translate("skills.plugin_management_host_only"));
      setPluginList([]);
      setSidebarPluginStatus(translate("skills.plugins_host_only"));
      setSidebarPluginList([]);
      refreshPluginsInFlight = false;
      return;
    }

    if (scope === "project" && !targetDir) {
      setPluginStatus(translate("skills.pick_project_for_plugins"));
      setPluginList([]);
      setSidebarPluginStatus(translate("skills.pick_project_for_active"));
      setSidebarPluginList([]);
      refreshPluginsInFlight = false;
      return;
    }

    try {
      setPluginStatus(null);
      setSidebarPluginStatus(null);

      if (refreshPluginsAborted) return;

      const config = await readOpencodeConfig(scope, targetDir);

      if (refreshPluginsAborted) return;

      setPluginConfig(config);
      setPluginConfigPath(config.path ?? null);

      if (!config.exists) {
        setPluginList([]);
        setPluginStatus(translate("skills.no_opencode_found"));
        setSidebarPluginList([]);
        setSidebarPluginStatus(translate("skills.no_opencode_workspace"));
        return;
      }

      try {
        const next = parsePluginListFromContent(config.content ?? "");
        setSidebarPluginList(next);
      } catch {
        setSidebarPluginList([]);
        setSidebarPluginStatus(translate("skills.failed_parse_opencode"));
      }

      loadPluginsFromConfig(config);
    } catch (e) {
      if (refreshPluginsAborted) return;
      setPluginConfig(null);
      setPluginConfigPath(null);
      setPluginList([]);
      setPluginStatus(e instanceof Error ? e.message : translate("skills.failed_load_opencode"));
      setSidebarPluginStatus(translate("skills.failed_load_active"));
      setSidebarPluginList([]);
    } finally {
      refreshPluginsInFlight = false;
    }
  }

  async function addPlugin(pluginNameOverride?: string) {
    const pluginName = (pluginNameOverride ?? pluginInput()).trim();
    const isManualInput = pluginNameOverride == null;

    const isRemoteWorkspace = options.workspaceType() === "remote";
    const isHostMode = options.mode() === "host";
    const openworkClient = options.openworkServerClient();
    const openworkWorkspaceId = options.openworkServerWorkspaceId();
    const openworkCapabilities = options.openworkServerCapabilities();
    const canUseOpenworkServer =
      options.openworkServerStatus() === "connected" &&
      openworkClient &&
      openworkWorkspaceId &&
      openworkCapabilities?.plugins?.write;

    if (!pluginName) {
      if (isManualInput) {
        setPluginStatus(translate("skills.enter_plugin_name"));
      }
      return;
    }

    if (isRemoteWorkspace) {
      if (pluginScope() !== "project") {
        setPluginStatus("Global plugins are only available in Host mode.");
        return;
      }
      if (!canUseOpenworkServer) {
        setPluginStatus("OpenWork server unavailable. Connect to add plugins.");
        return;
      }

      try {
        setPluginStatus(null);
        await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
        if (isManualInput) {
          setPluginInput("");
        }
        await refreshPlugins("project");
      } catch (e) {
        setPluginStatus(e instanceof Error ? e.message : "Failed to add plugin.");
      }
      return;
    }

    // Host mode with OpenWork server: use server for project scope
    if (isHostMode && pluginScope() === "project" && canUseOpenworkServer) {
      try {
        setPluginStatus(null);
        await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
        if (isManualInput) {
          setPluginInput("");
        }
        await refreshPlugins("project");
      } catch (e) {
        setPluginStatus(e instanceof Error ? e.message : "Failed to add plugin.");
      }
      return;
    }

    if (!isTauriRuntime()) {
      setPluginStatus(translate("skills.plugin_management_host_only"));
      return;
    }

    const scope = pluginScope();
    const targetDir = options.projectDir().trim();

    if (scope === "project" && !targetDir) {
      setPluginStatus(translate("skills.pick_project_for_plugins"));
      return;
    }

    try {
      setPluginStatus(null);
      const config = await readOpencodeConfig(scope, targetDir);
      const raw = config.content ?? "";

      if (!raw.trim()) {
        const payload = {
          $schema: "https://opencode.ai/config.json",
          plugin: [pluginName],
        };
        await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`);
        options.markReloadRequired("plugins");
        if (isManualInput) {
          setPluginInput("");
        }
        await refreshPlugins(scope);
        return;
      }

      const plugins = parsePluginListFromContent(raw);

      const desired = stripPluginVersion(pluginName).toLowerCase();
      if (plugins.some((entry) => stripPluginVersion(entry).toLowerCase() === desired)) {
        setPluginStatus(translate("skills.plugin_already_listed"));
        return;
      }

      const next = [...plugins, pluginName];
      const edits = modify(raw, ["plugin"], next, {
        formattingOptions: { insertSpaces: true, tabSize: 2 },
      });
      const updated = applyEdits(raw, edits);

      await writeOpencodeConfig(scope, targetDir, updated);
      options.markReloadRequired("plugins");
      if (isManualInput) {
        setPluginInput("");
      }
      await refreshPlugins(scope);
    } catch (e) {
      setPluginStatus(e instanceof Error ? e.message : translate("skills.failed_update_opencode"));
    }
  }

  async function importLocalSkill() {
    if (options.mode() !== "host" || !isTauriRuntime()) {
      options.setError(translate("skills.import_host_only"));
      return;
    }

    const targetDir = options.projectDir().trim();
    if (!targetDir) {
      options.setError(translate("skills.pick_project_first"));
      return;
    }

    options.setBusy(true);
    options.setError(null);
    setSkillsStatus(null);

    try {
      const selection = await pickDirectory({ title: translate("skills.select_skill_folder") });
      const sourceDir = typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null;

      if (!sourceDir) {
        return;
      }

      const result = await importSkill(targetDir, sourceDir, { overwrite: false });
      if (!result.ok) {
        setSkillsStatus(result.stderr || result.stdout || translate("skills.import_failed").replace("{status}", String(result.status)));
      } else {
        setSkillsStatus(result.stdout || translate("skills.imported"));
        options.markReloadRequired("skills");
      }

      await refreshSkills({ force: true });
    } catch (e) {
      const message = e instanceof Error ? e.message : translate("skills.unknown_error");
      options.setError(addOpencodeCacheHint(message));
    } finally {
      options.setBusy(false);
    }
  }

  async function installSkillCreator() {
    const isRemoteWorkspace = options.workspaceType() === "remote";
    const isHostMode = options.mode() === "host";
    const openworkClient = options.openworkServerClient();
    const openworkWorkspaceId = options.openworkServerWorkspaceId();
    const openworkCapabilities = options.openworkServerCapabilities();
    const canUseOpenworkServer =
      options.openworkServerStatus() === "connected" &&
      openworkClient &&
      openworkWorkspaceId &&
      openworkCapabilities?.skills?.write;

    // Remote workspace or host mode with server: use OpenWork server
    if ((isRemoteWorkspace || isHostMode) && canUseOpenworkServer) {
      options.setBusy(true);
      options.setError(null);
      setSkillsStatus(translate("skills.installing_skill_creator"));

      try {
        await openworkClient.upsertSkill(openworkWorkspaceId, {
          name: "skill-creator",
          content: skillCreatorTemplate,
        });
        setSkillsStatus(translate("skills.skill_creator_installed"));
        await refreshSkills({ force: true });
      } catch (e) {
        const message = e instanceof Error ? e.message : translate("skills.unknown_error");
        options.setError(addOpencodeCacheHint(message));
      } finally {
        options.setBusy(false);
      }
      return;
    }

    // Remote workspace without server
    if (isRemoteWorkspace) {
      setSkillsStatus("OpenWork server unavailable. Connect to install skills.");
      return;
    }

    if (!isTauriRuntime()) {
      setSkillsStatus(translate("skills.desktop_required"));
      return;
    }

    if (options.mode() !== "host") {
      options.setError(translate("skills.host_only_error"));
      return;
    }

    const targetDir = options.activeWorkspaceRoot().trim();
    if (!targetDir) {
      setSkillsStatus(translate("skills.pick_workspace_first"));
      return;
    }

    options.setBusy(true);
    options.setError(null);
    setSkillsStatus(translate("skills.installing_skill_creator"));

    try {
      const result = await installSkillTemplate(targetDir, "skill-creator", skillCreatorTemplate, { overwrite: false });

      if (!result.ok && /already exists/i.test(result.stderr)) {
        setSkillsStatus(translate("skills.skill_creator_already_installed"));
      } else if (!result.ok) {
        setSkillsStatus(result.stderr || result.stdout || translate("skills.install_failed"));
      } else {
        setSkillsStatus(result.stdout || translate("skills.skill_creator_installed"));
        options.markReloadRequired("skills");
      }

      await refreshSkills({ force: true });
    } catch (e) {
      const message = e instanceof Error ? e.message : translate("skills.unknown_error");
      options.setError(addOpencodeCacheHint(message));
    } finally {
      options.setBusy(false);
    }
  }

  async function revealSkillsFolder() {
    if (!isTauriRuntime()) {
      setSkillsStatus(translate("skills.desktop_required"));
      return;
    }

    const root = options.activeWorkspaceRoot().trim();
    if (!root) {
      setSkillsStatus(translate("skills.pick_workspace_first"));
      return;
    }

    try {
      const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
      const opencodeSkills = await join(root, ".opencode", "skills");
      const claudeSkills = await join(root, ".claude", "skills");
      const legacySkills = await join(root, ".opencode", "skill");

      const tryOpen = async (target: string) => {
        try {
          await openPath(target);
          return true;
        } catch {
          return false;
        }
      };

      // Prefer opening the folder. `revealItemInDir` expects a file path on macOS.
      if (await tryOpen(opencodeSkills)) return;
      if (await tryOpen(claudeSkills)) return;
      if (await tryOpen(legacySkills)) return;
      await revealItemInDir(opencodeSkills);
    } catch (e) {
      setSkillsStatus(e instanceof Error ? e.message : translate("skills.reveal_failed"));
    }
  }

  async function uninstallSkill(name: string) {
    if (!isTauriRuntime()) {
      setSkillsStatus(translate("skills.desktop_required"));
      return;
    }

    if (options.mode() !== "host") {
      options.setError(translate("skills.host_only_error"));
      return;
    }

    const root = options.activeWorkspaceRoot().trim();
    if (!root) {
      setSkillsStatus(translate("skills.pick_workspace_first"));
      return;
    }

    const trimmed = name.trim();
    if (!trimmed) {
      return;
    }

    options.setBusy(true);
    options.setError(null);
    setSkillsStatus(null);

    try {
      const result = await uninstallSkillCommand(root, trimmed);
      if (!result.ok) {
        setSkillsStatus(result.stderr || result.stdout || translate("skills.uninstall_failed"));
      } else {
        setSkillsStatus(result.stdout || translate("skills.uninstalled"));
        options.markReloadRequired("skills");
      }

      await refreshSkills({ force: true });
    } catch (e) {
      const message = e instanceof Error ? e.message : translate("skills.unknown_error");
      options.setError(addOpencodeCacheHint(message));
    } finally {
      options.setBusy(false);
    }
  }

  function abortRefreshes() {
    refreshSkillsAborted = true;
    refreshPluginsAborted = true;
  }

  return {
    skills,
    skillsStatus,
    pluginScope,
    setPluginScope,
    pluginConfig,
    pluginConfigPath,
    pluginList,
    pluginInput,
    setPluginInput,
    pluginStatus,
    activePluginGuide,
    setActivePluginGuide,
    sidebarPluginList,
    sidebarPluginStatus,
    isPluginInstalledByName,
    refreshSkills,
    refreshPlugins,
    addPlugin,
    importLocalSkill,
    installSkillCreator,
    revealSkillsFolder,
    uninstallSkill,
    abortRefreshes,
  };
}



================================================
FILE: packages/app/src/app/context/global-sdk.tsx
================================================
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client";
import { createGlobalEmitter } from "@solid-primitives/event-bus";
import {
  batch,
  createContext,
  createEffect,
  createSignal,
  onCleanup,
  useContext,
  type ParentProps,
} from "solid-js";

import { usePlatform } from "./platform";
import { useServer } from "./server";

type GlobalSDKContextValue = {
  url: () => string;
  client: () => ReturnType<typeof createOpencodeClient>;
  event: ReturnType<typeof createGlobalEmitter<{ [key: string]: Event }>>;
};

const GlobalSDKContext = createContext<GlobalSDKContextValue | undefined>(undefined);

export function GlobalSDKProvider(props: ParentProps) {
  const server = useServer();
  const platform = usePlatform();
  const emitter = createGlobalEmitter<{ [key: string]: Event }>();
  const [client, setClient] = createSignal(
    createOpencodeClient({
      baseUrl: server.url,
      fetch: platform.fetch,
      throwOnError: true,
    }),
  );
  const [url, setUrl] = createSignal(server.url);

  createEffect(() => {
    const baseUrl = server.url;
    setUrl(baseUrl);

    const abort = new AbortController();
    const eventClient = createOpencodeClient({
      baseUrl,
      signal: abort.signal,
      fetch: platform.fetch,
    });

    setClient(
      createOpencodeClient({
        baseUrl,
        fetch: platform.fetch,
        throwOnError: true,
      }),
    );

    type Queued = { directory: string; payload: Event };

    let queue: Array<Queued | undefined> = [];
    const coalesced = new Map<string, number>();
    let timer: ReturnType<typeof setTimeout> | undefined;
    let last = 0;

    const keyForEvent = (directory: string, payload: Event) => {
      if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`;
      if (payload.type === "lsp.updated") return `lsp.updated:${directory}`;
      if (payload.type === "todo.updated") return `todo.updated:${directory}:${payload.properties.sessionID}`;
      if (payload.type === "mcp.tools.changed") return `mcp.tools.changed:${directory}:${payload.properties.server}`;
      if (payload.type === "message.part.updated") {
        const part = payload.properties.part;
        return `message.part.updated:${directory}:${part.messageID}:${part.id}`;
      }
    };

    const flush = () => {
      if (timer) clearTimeout(timer);
      timer = undefined;

      const events = queue;
      queue = [];
      coalesced.clear();
      if (events.length === 0) return;

      last = Date.now();
      batch(() => {
        for (const entry of events) {
          if (!entry) continue;
          emitter.emit(entry.directory, entry.payload);
        }
      });
    };

    const schedule = () => {
      if (timer) return;
      const elapsed = Date.now() - last;
      timer = setTimeout(flush, Math.max(0, 16 - elapsed));
    };

    const stop = () => {
      flush();
    };

    void (async () => {
      const subscription = await eventClient.event.subscribe(undefined, { signal: abort.signal });
      let yielded = Date.now();

      for await (const event of subscription.stream as AsyncIterable<unknown>) {
        const record = event as Event & { directory?: string; payload?: Event };
        const payload = record.payload ?? record;
        if (!payload?.type) continue;

        const directory = typeof record.directory === "string" ? record.directory : "global";
        const key = keyForEvent(directory, payload);
        if (key) {
          const index = coalesced.get(key);
          if (index !== undefined) {
            queue[index] = undefined;
          }
          coalesced.set(key, queue.length);
        }

        queue.push({ directory, payload });
        schedule();

        if (Date.now() - yielded < 8) continue;
        yielded = Date.now();
        await new Promise<void>((resolve) => setTimeout(resolve, 0));
      }
    })()
      .finally(stop)
      .catch(() => undefined);

    onCleanup(() => {
      abort.abort();
      stop();
    });
  });

  const value: GlobalSDKContextValue = {
    url,
    client,
    event: emitter,
  };

  return <GlobalSDKContext.Provider value={value}>{props.children}</GlobalSDKContext.Provider>;
}

export function useGlobalSDK() {
  const context = useContext(GlobalSDKContext);
  if (!context) {
    throw new Error("Global SDK context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/global-sync.tsx
================================================
import { createContext, createEffect, useContext, type ParentProps } from "solid-js";
import { createStore, type SetStoreFunction, type Store } from "solid-js/store";

import type {
  Command,
  Config,
  ConfigProvidersResponse,
  Event,
  GlobalHealthResponse,
  LspStatus,
  Project,
  ProviderListResponse,
  ProviderAuthResponse,
  Message,
  Part,
  Session,
  VcsInfo,
} from "@opencode-ai/sdk/v2/client";

import type { McpStatusMap, TodoItem } from "../types";
import { unwrap } from "../lib/opencode";
import { safeStringify } from "../utils";
import { mapConfigProvidersToList } from "../utils/providers";
import { useGlobalSDK } from "./global-sdk";

export type WorkspaceState = {
  status: "idle" | "loading" | "partial" | "ready";
  session: Session[];
  session_status: Record<string, string>;
  message: Record<string, Message[]>;
  part: Record<string, Part[]>;
  todo: Record<string, TodoItem[]>;
};

type WorkspaceStore = [Store<WorkspaceState>, SetStoreFunction<WorkspaceState>];

type ProjectMeta = {
  name?: string;
  icon?: Project["icon"];
};

type GlobalState = {
  ready: boolean;
  error?: string;
  serverVersion?: string;
  config: Config;
  provider: ProviderListResponse;
  providerAuth: ProviderAuthResponse;
  command: Record<string, Command[]>;
  mcp: Record<string, McpStatusMap>;
  lsp: Record<string, LspStatus[]>;
  project: Project[];
  projectMeta: Record<string, ProjectMeta>;
  vcs: Record<string, VcsInfo | null>;
};

type GlobalSyncContextValue = {
  data: Store<GlobalState>;
  set: SetStoreFunction<GlobalState>;
  child: (directory: string) => WorkspaceStore;
  refresh: () => Promise<void>;
  refreshDirectory: (directory: string) => Promise<void>;
};

const GlobalSyncContext = createContext<GlobalSyncContextValue | undefined>(undefined);

const createWorkspaceState = (): WorkspaceState => ({
  status: "idle",
  session: [],
  session_status: {},
  message: {},
  part: {},
  todo: {},
});

export function GlobalSyncProvider(props: ParentProps) {
  const globalSDK = useGlobalSDK();
  const defaultProvider: ProviderListResponse = { all: [], connected: [], default: {} };
  const [globalStore, setGlobalStore] = createStore<GlobalState>({
    ready: false,
    error: undefined,
    serverVersion: undefined,
    config: {},
    provider: defaultProvider,
    providerAuth: {},
    command: {},
    mcp: {},
    lsp: {},
    project: [],
    projectMeta: {},
    vcs: {},
  });
  const children = new Map<string, WorkspaceStore>();
  const subscriptions = new Map<string, () => void>();

  const keyFor = (directory: string) => directory || "global";

  const setError = (error: unknown) => {
    const message = error instanceof Error ? error.message : safeStringify(error);
    setGlobalStore("error", message || "Unknown error");
  };

  const setProjectMeta = (projects: Project[]) => {
    const next: Record<string, ProjectMeta> = {};
    for (const project of projects) {
      if (!project?.worktree) continue;
      next[project.worktree] = {
        name: project.name,
        icon: project.icon,
      };
    }
    setGlobalStore("projectMeta", next);
  };

  const refreshConfig = async () => {
    const result = unwrap(await globalSDK.client().config.get());
    setGlobalStore("config", result);
  };

  const refreshProviders = async () => {
    try {
      const result = unwrap(await globalSDK.client().provider.list());
      setGlobalStore("provider", result);
    } catch {
      const fallback = unwrap(await globalSDK.client().config.providers()) as ConfigProvidersResponse;
      setGlobalStore("provider", {
        all: mapConfigProvidersToList(fallback.providers),
        connected: [],
        default: fallback.default,
      });
    }
  };

  const refreshProviderAuth = async () => {
    try {
      const result = await globalSDK.client().provider.auth();
      setGlobalStore("providerAuth", result.data ?? {});
    } catch {
      setGlobalStore("providerAuth", {});
    }
  };

  const refreshCommands = async (directory?: string) => {
    const result = unwrap(await globalSDK.client().command.list({ directory })) as Command[];
    setGlobalStore("command", keyFor(directory ?? ""), result);
  };

  const refreshMcp = async (directory?: string) => {
    const result = unwrap(await globalSDK.client().mcp.status({ directory })) as McpStatusMap;
    setGlobalStore("mcp", keyFor(directory ?? ""), result as McpStatusMap);
  };

  const refreshLsp = async (directory?: string) => {
    const result = unwrap(await globalSDK.client().lsp.status({ directory })) as LspStatus[];
    setGlobalStore("lsp", keyFor(directory ?? ""), result as LspStatus[]);
  };

  const refreshVcs = async (directory: string) => {
    try {
      const result = unwrap(await globalSDK.client().vcs.get({ directory })) as VcsInfo;
      setGlobalStore("vcs", keyFor(directory), result ?? null);
    } catch {
      setGlobalStore("vcs", keyFor(directory), null);
    }
  };

  const refreshProjects = async () => {
    const projects = unwrap(await globalSDK.client().project.list()) as Project[];
    setGlobalStore("project", projects);
    setProjectMeta(projects);
    await Promise.allSettled(
      projects
        .map((project) => project.worktree)
        .filter((worktree): worktree is string => typeof worktree === "string" && worktree.length > 0)
        .map((worktree) => refreshVcs(worktree)),
    );
  };

  const refreshDirectory = async (directory: string) => {
    if (!directory) return;
    await Promise.allSettled([
      refreshCommands(directory),
      refreshMcp(directory),
      refreshLsp(directory),
      refreshVcs(directory),
    ]);
  };

  const refresh = async () => {
    setGlobalStore("ready", false);
    setGlobalStore("error", undefined);

    try {
      const health = unwrap(await globalSDK.client().global.health()) as GlobalHealthResponse;
      if (!health?.healthy) {
        setGlobalStore("error", "Server reported unhealthy status.");
        return;
      }

      if (globalStore.serverVersion && health.version !== globalStore.serverVersion) {
        setGlobalStore("command", {});
        setGlobalStore("mcp", {});
        setGlobalStore("lsp", {});
        setGlobalStore("project", []);
        setGlobalStore("projectMeta", {});
        setGlobalStore("vcs", {});
      }
      setGlobalStore("serverVersion", health.version);
    } catch (error) {
      setError(error);
      return;
    }

    const results = await Promise.allSettled([
      refreshConfig(),
      refreshProviders(),
      refreshProviderAuth(),
      refreshCommands(),
      refreshMcp(),
      refreshLsp(),
      refreshProjects(),
    ]);

    for (const result of results) {
      if (result.status === "rejected") {
        setError(result.reason);
      }
    }

    setGlobalStore("ready", true);
  };

  const child = (directory: string): WorkspaceStore => {
    const key = keyFor(directory);
    const existing = children.get(key);
    if (existing) return existing;
    const store = createStore<WorkspaceState>(createWorkspaceState());
    children.set(key, store);
    void refreshDirectory(directory);
    if (!subscriptions.has(key)) {
      const unsubscribe = globalSDK.event.listen((payload) => {
        if (payload.name !== key) return;
        const event = payload.details as Event;
        if (event.type === "lsp.updated") {
          void refreshLsp(directory);
        }
        if (event.type === "mcp.tools.changed") {
          void refreshMcp(directory);
        }
      });
      subscriptions.set(key, unsubscribe);
    }
    return store;
  };

  const value: GlobalSyncContextValue = {
    data: globalStore,
    set: setGlobalStore,
    child,
    refresh,
    refreshDirectory,
  };

  createEffect(() => {
    const url = globalSDK.url();
    if (!url) return;
    void refresh();
  });

  const globalKey = keyFor("");
  if (!subscriptions.has(globalKey)) {
    const unsubscribe = globalSDK.event.listen((payload) => {
      if (payload.name !== globalKey) return;
      const event = payload.details as Event;
      if (event.type === "lsp.updated") {
        void refreshLsp();
      }
      if (event.type === "mcp.tools.changed") {
        void refreshMcp();
      }
    });
    subscriptions.set(globalKey, unsubscribe);
  }

  return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>;
}

export function useGlobalSync() {
  const context = useContext(GlobalSyncContext);
  if (!context) {
    throw new Error("Global sync context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/local.tsx
================================================
import { createContext, useContext, type ParentProps } from "solid-js";
import { createStore, type SetStoreFunction, type Store } from "solid-js/store";

import type { DashboardTab, ModelRef, View } from "../types";
import { Persist, persisted } from "../utils/persist";

type LocalUIState = {
  view: View;
  tab: DashboardTab;
};

type LocalPreferences = {
  showThinking: boolean;
  modelVariant: string | null;
  defaultModel: ModelRef | null;
};

type LocalContextValue = {
  ui: Store<LocalUIState>;
  setUi: SetStoreFunction<LocalUIState>;
  prefs: Store<LocalPreferences>;
  setPrefs: SetStoreFunction<LocalPreferences>;
  ready: () => boolean;
};

const LocalContext = createContext<LocalContextValue | undefined>(undefined);

export function LocalProvider(props: ParentProps) {
  const [ui, setUi, , uiReady] = persisted(
    Persist.global("local.ui", ["openwork.ui"]),
    createStore<LocalUIState>({
      view: "onboarding",
      tab: "home",
    }),
  );

  const [prefs, setPrefs, , prefsReady] = persisted(
    Persist.global("local.preferences", ["openwork.preferences"]),
    createStore<LocalPreferences>({
      showThinking: false,
      modelVariant: null,
      defaultModel: null,
    }),
  );

  const ready = () => uiReady() && prefsReady();

  const value: LocalContextValue = {
    ui,
    setUi,
    prefs,
    setPrefs,
    ready,
  };

  return <LocalContext.Provider value={value}>{props.children}</LocalContext.Provider>;
}

export function useLocal() {
  const context = useContext(LocalContext);
  if (!context) {
    throw new Error("Local context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/platform.tsx
================================================
import { createContext, useContext, type ParentProps } from "solid-js";

export type SyncStorage = {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
};

export type AsyncStorage = {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
};

export type Platform = {
  platform: "web" | "desktop";
  os?: "macos" | "windows" | "linux";
  version?: string;
  openLink(url: string): void;
  restart(): Promise<void>;
  notify(title: string, description?: string, href?: string): Promise<void>;
  storage?: (name?: string) => SyncStorage | AsyncStorage;
  checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>;
  update?: () => Promise<void>;
  fetch?: typeof fetch;
  getDefaultServerUrl?: () => Promise<string | null>;
  setDefaultServerUrl?: (url: string | null) => Promise<void>;
};

const PlatformContext = createContext<Platform | undefined>(undefined);

export function PlatformProvider(props: ParentProps & { value: Platform }) {
  return (
    <PlatformContext.Provider value={props.value}>
      {props.children}
    </PlatformContext.Provider>
  );
}

export function usePlatform() {
  const context = useContext(PlatformContext);
  if (!context) {
    throw new Error("Platform context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/server.tsx
================================================
import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";

export function normalizeServerUrl(input: string) {
  const trimmed = input.trim();
  if (!trimmed) return;
  const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
  return withProtocol.replace(/\/+$/, "");
}

export function serverDisplayName(url: string) {
  if (!url) return "";
  return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
}

type ServerContextValue = {
  url: string;
  name: string;
  list: string[];
  healthy: () => boolean | undefined;
  setActive: (url: string) => void;
  add: (url: string) => void;
  remove: (url: string) => void;
};

const ServerContext = createContext<ServerContextValue | undefined>(undefined);

export function ServerProvider(props: ParentProps & { defaultUrl: string }) {
  const [list, setList] = createSignal<string[]>([]);
  const [active, setActiveRaw] = createSignal("");
  const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined);
  const [ready, setReady] = createSignal(false);

  const readStoredList = () => {
    try {
      const raw = window.localStorage.getItem("openwork.server.list");
      const parsed = raw ? (JSON.parse(raw) as unknown) : [];
      return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
    } catch {
      return [];
    }
  };

  const readStoredActive = () => {
    try {
      const stored = window.localStorage.getItem("openwork.server.active");
      return typeof stored === "string" ? stored : "";
    } catch {
      return "";
    }
  };

  createEffect(() => {
    if (typeof window === "undefined") return;
    if (ready()) return;

    const storedList = readStoredList();
    const fallback = normalizeServerUrl(props.defaultUrl) ?? "";
    const storedActive = normalizeServerUrl(readStoredActive());

    const initialList = storedList.length ? storedList : fallback ? [fallback] : [];
    const initialActive = storedActive || initialList[0] || fallback || "";

    setList(initialList);
    setActiveRaw(initialActive);
    setReady(true);
  });

  createEffect(() => {
    if (!ready()) return;
    if (typeof window === "undefined") return;

    try {
      window.localStorage.setItem("openwork.server.list", JSON.stringify(list()));
      window.localStorage.setItem("openwork.server.active", active());
    } catch {
      // ignore
    }
  });

  const activeUrl = createMemo(() => active());

  const checkHealth = async (url: string) => {
    if (!url) return false;
    const client = createOpencodeClient({
      baseUrl: url,
      signal: AbortSignal.timeout(3000),
    });
    return client.global
      .health()
      .then((result) => result.data?.healthy === true)
      .catch(() => false);
  };

  createEffect(() => {
    const url = activeUrl();
    if (!url) return;

    setHealthy(undefined);

    let activeRun = true;
    let busy = false;

    const run = () => {
      if (busy) return;
      busy = true;
      void checkHealth(url)
        .then((next) => {
          if (!activeRun) return;
          setHealthy(next);
        })
        .finally(() => {
          busy = false;
        });
    };

    run();
    const interval = window.setInterval(run, 10_000);

    onCleanup(() => {
      activeRun = false;
      window.clearInterval(interval);
    });
  });

  const setActive = (input: string) => {
    const next = normalizeServerUrl(input);
    if (!next) return;
    setActiveRaw(next);
  };

  const add = (input: string) => {
    const next = normalizeServerUrl(input);
    if (!next) return;

    setList((current) => {
      if (current.includes(next)) return current;
      return [...current, next];
    });
    setActiveRaw(next);
  };

  const remove = (input: string) => {
    const next = normalizeServerUrl(input);
    if (!next) return;

    setList((current) => current.filter((item) => item !== next));
    setActiveRaw((current) => {
      if (current !== next) return current;
      const remaining = list().filter((item) => item !== next);
      return remaining[0] ?? "";
    });
  };

  const value: ServerContextValue = {
    get url() {
      return activeUrl();
    },
    get name() {
      return serverDisplayName(activeUrl());
    },
    get list() {
      return list();
    },
    healthy,
    setActive,
    add,
    remove,
  };

  return <ServerContext.Provider value={value}>{props.children}</ServerContext.Provider>;
}

export function useServer() {
  const context = useContext(ServerContext);
  if (!context) {
    throw new Error("Server context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/session.ts
================================================
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";

import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client";

import type {
  Client,
  MessageInfo,
  MessageWithParts,
  ModelRef,
  OpencodeEvent,
  PendingPermission,
  PlaceholderAssistantMessage,
  ReloadReason,
  TodoItem,
} from "../types";
import {
  addOpencodeCacheHint,
  modelFromUserMessage,
  normalizeDirectoryPath,
  normalizeEvent,
  normalizeSessionStatus,
  safeStringify,
} from "../utils";
import { unwrap } from "../lib/opencode";

export type SessionModelState = {
  overrides: Record<string, ModelRef>;
  resolved: Record<string, ModelRef>;
};

export type SessionStore = ReturnType<typeof createSessionStore>;

type StoreState = {
  sessions: Session[];
  sessionStatus: Record<string, string>;
  messages: Record<string, MessageInfo[]>;
  parts: Record<string, Part[]>;
  todos: Record<string, TodoItem[]>;
  pendingPermissions: PendingPermission[];
  events: OpencodeEvent[];
};

const sortById = <T extends { id: string }>(list: T[]) =>
  list.slice().sort((a, b) => a.id.localeCompare(b.id));

const sessionActivity = (session: Session) =>
  session.time?.updated ?? session.time?.created ?? 0;

const sortSessionsByActivity = (list: Session[]) =>
  list
    .slice()
    .sort((a, b) => {
      const delta = sessionActivity(b) - sessionActivity(a);
      if (delta !== 0) return delta;
      return a.id.localeCompare(b.id);
    });

const createPlaceholderMessage = (part: Part): PlaceholderAssistantMessage => ({
  id: part.messageID,
  sessionID: part.sessionID,
  role: "assistant",
  time: { created: Date.now() },
  parentID: "",
  modelID: "",
  providerID: "",
  mode: "",
  agent: "",
  path: { cwd: "", root: "" },
  cost: 0,
  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
});

const upsertSession = (list: Session[], next: Session) => {
  const index = list.findIndex((session) => session.id === next.id);
  if (index === -1) return sortSessionsByActivity([...list, next]);
  const copy = list.slice();
  copy[index] = next;
  return sortSessionsByActivity(copy);
};

const removeSession = (list: Session[], sessionID: string) => list.filter((session) => session.id !== sessionID);

const upsertMessageInfo = (list: MessageInfo[], next: MessageInfo) => {
  const index = list.findIndex((message) => message.id === next.id);
  if (index === -1) return sortById([...list, next]);
  const copy = list.slice();
  copy[index] = next;
  return copy;
};

const removeMessageInfo = (list: MessageInfo[], messageID: string) =>
  list.filter((message) => message.id !== messageID);

const upsertPartInfo = (list: Part[], next: Part) => {
  const index = list.findIndex((part) => part.id === next.id);
  if (index === -1) return sortById([...list, next]);
  const copy = list.slice();
  copy[index] = next;
  return copy;
};

const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID);

export function createSessionStore(options: {
  client: () => Client | null;
  selectedSessionId: () => string | null;
  setSelectedSessionId: (id: string | null) => void;
  sessionModelState: () => SessionModelState;
  setSessionModelState: (updater: (current: SessionModelState) => SessionModelState) => SessionModelState;
  lastUserModelFromMessages: (messages: MessageWithParts[]) => ModelRef | null;
  developerMode: () => boolean;
  setError: (message: string | null) => void;
  setSseConnected: (connected: boolean) => void;
  markReloadRequired?: (reason: ReloadReason) => void;
}) {
  const [store, setStore] = createStore<StoreState>({
    sessions: [],
    sessionStatus: {},
    messages: {},
    parts: {},
    todos: {},
    pendingPermissions: [],
    events: [],
  });
  const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false);
  const reloadDetectionSet = new Set<string>();

  const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i;
  const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i;
  const opencodePathPattern = /(?:^|[\\/])\.opencode[\\/]/i;
  const mutatingTools = new Set(["write", "edit", "apply_patch"]);

  const extractSearchText = (value: unknown) => {
    if (!value) return "";
    if (typeof value === "string") return value;
    if (typeof value === "number") return String(value);
    return safeStringify(value);
  };

  const detectReloadReason = (value: unknown): ReloadReason | null => {
    const text = extractSearchText(value);
    if (!text) return null;
    if (skillPathPattern.test(text)) return "skills";
    if (opencodeConfigPattern.test(text)) return "config";
    if (opencodePathPattern.test(text)) return "config";
    return null;
  };

  const detectReloadReasonDeep = (value: unknown): ReloadReason | null => {
    if (!value) return null;
    if (typeof value === "string" || typeof value === "number") {
      return detectReloadReason(value);
    }
    if (Array.isArray(value)) {
      for (const entry of value) {
        const reason = detectReloadReasonDeep(entry);
        if (reason) return reason;
      }
      return null;
    }
    if (typeof value === "object") {
      for (const entry of Object.values(value as Record<string, unknown>)) {
        const reason = detectReloadReasonDeep(entry);
        if (reason) return reason;
      }
    }
    return null;
  };

  const detectReloadFromPart = (part: Part): ReloadReason | null => {
    if (part.type !== "tool") return null;
    const record = part as Record<string, unknown>;
    const toolName = typeof record.tool === "string" ? record.tool : "";
    if (!mutatingTools.has(toolName)) return null;
    const state = (record.state ?? {}) as Record<string, unknown>;
    return (
      detectReloadReasonDeep(state.input) ||
      detectReloadReasonDeep(state.patch) ||
      detectReloadReasonDeep(state.diff)
    );
  };

  const maybeMarkReloadRequired = (part: Part) => {
    if (!options.markReloadRequired) return;
    if (!part?.id || !part.messageID) return;
    const key = `${part.messageID}:${part.id}`;
    if (reloadDetectionSet.has(key)) return;
    const reason = detectReloadFromPart(part);
    if (!reason) return;
    reloadDetectionSet.add(key);
    options.markReloadRequired(reason);
  };

  const addError = (error: unknown, fallback = "Unknown error") => {
    const message = error instanceof Error ? error.message : fallback;
    if (!message) return;
    options.setError(addOpencodeCacheHint(message));
  };

  const withTimeout = async <T,>(promise: Promise<T>, ms: number, label: string) => {
    let timeoutId: ReturnType<typeof setTimeout> | null = null;
    const timeoutPromise = new Promise<never>((_, reject) => {
      timeoutId = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms);
    });
    try {
      return await Promise.race([promise, timeoutPromise]);
    } finally {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
    }
  };

  const sessions = () => store.sessions;
  const sessionStatusById = () => store.sessionStatus;
  const pendingPermissions = () => store.pendingPermissions;
  const events = () => store.events;

  const selectedSession = createMemo(() => {
    const id = options.selectedSessionId();
    if (!id) return null;
    return store.sessions.find((session) => session.id === id) ?? null;
  });

  const selectedSessionStatus = createMemo(() => {
    const id = options.selectedSessionId();
    if (!id) return "idle";
    return store.sessionStatus[id] ?? "idle";
  });

  const messages = createMemo<MessageWithParts[]>(() => {
    const id = options.selectedSessionId();
    if (!id) return [];
    const list = store.messages[id] ?? [];
    return list.map((info) => ({ info, parts: store.parts[info.id] ?? [] }));
  });

  const todos = createMemo<TodoItem[]>(() => {
    const id = options.selectedSessionId();
    if (!id) return [];
    return store.todos[id] ?? [];
  });

  async function loadSessions(scopeRoot?: string) {
    const c = options.client();
    if (!c) return;
    const list = unwrap(await c.session.list());
    const root = normalizeDirectoryPath(scopeRoot);
    const filtered = root
      ? list.filter((session) => normalizeDirectoryPath(session.directory) === root)
      : list;
    setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));
  }

  async function renameSession(sessionID: string, title: string) {
    const c = options.client();
    if (!c) return;
    const trimmed = title.trim();
    if (!trimmed) {
      throw new Error("Session name is required");
    }
    const next = unwrap(await c.session.update({ sessionID, title: trimmed }));
    setStore("sessions", (current) => upsertSession(current, next));
  }

  async function refreshPendingPermissions() {
    const c = options.client();
    if (!c) return;
    const list = unwrap(await c.permission.list());
    const now = Date.now();
    const byId = new Map(store.pendingPermissions.map((perm) => [perm.id, perm] as const));
    const next = list.map((perm) => ({ ...perm, receivedAt: byId.get(perm.id)?.receivedAt ?? now }));
    setStore("pendingPermissions", next);
  }

  function setMessagesForSession(sessionID: string, list: MessageWithParts[]) {
    const infos = list
      .map((msg) => msg.info)
      .filter((info) => !!info?.id)
      .map((info) => info as MessageInfo);

    batch(() => {
      setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" }));
      for (const message of list) {
        const parts = message.parts.filter((part) => !!part?.id);
        setStore("parts", message.info.id, reconcile(sortById(parts), { key: "id" }));
      }
    });
  }

  async function selectSession(sessionID: string) {
    const c = options.client();
    if (!c) return;

    const runId = (() => {
      const key = "__openwork_select_session_run__";
      const w = window as typeof window & { [key]?: number };
      w[key] = (w[key] ?? 0) + 1;
      return w[key];
    })();
    const mark = (() => {
      const start = Date.now();
      return (label: string) => console.log(`[selectSession run ${runId}] ${label} (+${Date.now() - start}ms)`);
    })();

    mark("start");
    options.setSelectedSessionId(sessionID);
    options.setError(null);

    mark("checking health");
    try {
      await withTimeout(c.global.health(), 3000, "health");
      mark("health ok");
    } catch {
      mark("health FAILED");
      throw new Error("Server connection lost. Please reload.");
    }

    mark("calling session.messages");
    const msgs = unwrap(await withTimeout(c.session.messages({ sessionID }), 12000, "session.messages"));
    mark("session.messages done");
    if (options.selectedSessionId() !== sessionID) {
      mark("aborting: selection changed before messages applied");
      return;
    }
    setMessagesForSession(sessionID, msgs);

    const model = options.lastUserModelFromMessages(msgs);
    if (model) {
      if (options.selectedSessionId() !== sessionID) {
        mark("aborting: selection changed before model applied");
        return;
      }
      options.setSessionModelState((current) => ({
        overrides: current.overrides,
        resolved: { ...current.resolved, [sessionID]: model },
      }));

      options.setSessionModelState((current) => {
        if (!current.overrides[sessionID]) return current;
        const copy = { ...current.overrides };
        delete copy[sessionID];
        return { ...current, overrides: copy };
      });
    }

    try {
      mark("calling session.todo");
      const list = unwrap(await withTimeout(c.session.todo({ sessionID }), 8000, "session.todo"));
      mark("session.todo done");
      if (options.selectedSessionId() !== sessionID) {
        mark("aborting: selection changed before todos applied");
        return;
      }
      setStore("todos", sessionID, list);
    } catch {
      mark("session.todo failed/timeout");
      setStore("todos", sessionID, []);
    }

    try {
      mark("calling permission.list");
      await withTimeout(refreshPendingPermissions(), 6000, "permission.list");
      mark("permission.list done");
      if (options.selectedSessionId() !== sessionID) {
        mark("aborting: selection changed before permissions applied");
        return;
      }
    } catch {
      mark("permission.list failed/timeout");
    }

    mark("selectSession complete");
  }

  async function respondPermission(requestID: string, reply: "once" | "always" | "reject") {
    const c = options.client();
    if (!c || permissionReplyBusy()) return;

    setPermissionReplyBusy(true);
    options.setError(null);

    try {
      unwrap(await c.permission.reply({ requestID, reply }));
      await refreshPendingPermissions();
    } catch (e) {
      addError(e);
    } finally {
      setPermissionReplyBusy(false);
    }
  }

  const setSessions = (next: Session[]) => {
    setStore("sessions", reconcile(sortSessionsByActivity(next), { key: "id" }));
  };

  const setSessionStatusById = (next: Record<string, string>) => {
    setStore("sessionStatus", next);
  };

  const setMessages = (next: MessageWithParts[]) => {
    const id = options.selectedSessionId();
    if (!id) return;
    setMessagesForSession(id, next);
  };

  const setTodos = (next: TodoItem[]) => {
    const id = options.selectedSessionId();
    if (!id) return;
    setStore("todos", id, next);
  };

  const setPendingPermissions = (next: PendingPermission[]) => {
    setStore("pendingPermissions", next);
  };

  const activePermission = createMemo(() => {
    const id = options.selectedSessionId();
    if (id) {
      return store.pendingPermissions.find((perm) => perm.sessionID === id) ?? null;
    }
    return store.pendingPermissions[0] ?? null;
  });

  const applyEvent = async (event: OpencodeEvent) => {
    if (event.type === "server.connected") {
      options.setSseConnected(true);
    }

    if (options.developerMode()) {
      setStore("events", (current) => {
        const next = [{ type: event.type, properties: event.properties }, ...current];
        return next.slice(0, 150);
      });
    }

    if (event.type === "session.updated" || event.type === "session.created") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        if (record.info && typeof record.info === "object") {
          const info = record.info as Session;
          setStore("sessions", (current) => upsertSession(current, info));
        }
      }
    }

    if (event.type === "session.deleted") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const info = record.info as Session | undefined;
        if (info?.id) {
          setStore("sessions", (current) => removeSession(current, info.id));
        }
      }
    }

    if (event.type === "session.status") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
        if (sessionID) {
          setStore("sessionStatus", sessionID, normalizeSessionStatus(record.status));
        }
      }
    }

    if (event.type === "session.idle") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
        if (sessionID) {
          setStore("sessionStatus", sessionID, "idle");
        }
      }
    }

    if (event.type === "message.updated") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        if (record.info && typeof record.info === "object") {
          const info = record.info as Message;
          const model = modelFromUserMessage(info as MessageInfo);
          if (model) {
            options.setSessionModelState((current) => ({
              overrides: current.overrides,
              resolved: { ...current.resolved, [info.sessionID]: model },
            }));

            options.setSessionModelState((current) => {
              if (!current.overrides[info.sessionID]) return current;
              const copy = { ...current.overrides };
              delete copy[info.sessionID];
              return { ...current, overrides: copy };
            });
          }

          setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info));
        }
      }
    }

    if (event.type === "message.removed") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
        const messageID = typeof record.messageID === "string" ? record.messageID : null;
        if (sessionID && messageID) {
          setStore("messages", sessionID, (current = []) => removeMessageInfo(current, messageID));
          setStore("parts", messageID, []);
        }
      }
    }

    if (event.type === "message.part.updated") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        if (record.part && typeof record.part === "object") {
          const part = record.part as Part;
          const delta = typeof record.delta === "string" ? record.delta : null;

          setStore(
            produce((draft: StoreState) => {
              const list = draft.messages[part.sessionID] ?? [];
              if (!list.find((message) => message.id === part.messageID)) {
                draft.messages[part.sessionID] = upsertMessageInfo(list, createPlaceholderMessage(part));
              }

              const parts = draft.parts[part.messageID] ?? [];
              const existingIndex = parts.findIndex((item) => item.id === part.id);

              if (delta && part.type === "text" && existingIndex !== -1) {
                const existing = parts[existingIndex] as Part & { text?: string };
                if (typeof existing.text === "string" && !existing.text.endsWith(delta)) {
                  const next = { ...existing, text: `${existing.text}${delta}` } as Part;
                  parts[existingIndex] = next;
                  draft.parts[part.messageID] = parts;
                  return;
                }
              }

              draft.parts[part.messageID] = upsertPartInfo(parts, part);
            }),
          );
          maybeMarkReloadRequired(part);
        }
      }
    }

    if (event.type === "message.part.removed") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const messageID = typeof record.messageID === "string" ? record.messageID : null;
        const partID = typeof record.partID === "string" ? record.partID : null;
        if (messageID && partID) {
          setStore("parts", messageID, (current = []) => removePartInfo(current, partID));
        }
      }
    }

    if (event.type === "todo.updated") {
      if (event.properties && typeof event.properties === "object") {
        const record = event.properties as Record<string, unknown>;
        const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
        if (sessionID && Array.isArray(record.todos)) {
          setStore("todos", sessionID, record.todos as TodoItem[]);
        }
      }
    }

    if (event.type === "permission.asked" || event.type === "permission.replied") {
      try {
        await refreshPendingPermissions();
      } catch {
        // ignore
      }
    }
  };

  createEffect(() => {
    const c = options.client();
    if (!c) return;

    const controller = new AbortController();
    let cancelled = false;

    let queue: Array<OpencodeEvent | undefined> = [];
    const coalesced = new Map<string, number>();
    let timer: ReturnType<typeof setTimeout> | undefined;
    let last = 0;

    const keyForEvent = (event: OpencodeEvent) => {
      if (event.type === "session.status" || event.type === "session.idle") {
        const record = event.properties as Record<string, unknown> | undefined;
        const sessionID = typeof record?.sessionID === "string" ? record.sessionID : "";
        return sessionID ? `${event.type}:${sessionID}` : undefined;
      }
      if (event.type === "message.part.updated") {
        const record = event.properties as Record<string, unknown> | undefined;
        const part = record?.part as Part | undefined;
        if (part?.messageID && part.id) {
          return `message.part.updated:${part.messageID}:${part.id}`;
        }
      }
      if (event.type === "todo.updated") {
        const record = event.properties as Record<string, unknown> | undefined;
        const sessionID = typeof record?.sessionID === "string" ? record.sessionID : "";
        return sessionID ? `todo.updated:${sessionID}` : undefined;
      }
      return undefined;
    };

    const flush = () => {
      if (timer) clearTimeout(timer);
      timer = undefined;

      const eventsToApply = queue;
      queue = [];
      coalesced.clear();
      if (eventsToApply.length === 0) return;

      last = Date.now();
      batch(() => {
        for (const event of eventsToApply) {
          if (!event) continue;
          void applyEvent(event);
        }
      });
    };

    const schedule = () => {
      if (timer) return;
      const elapsed = Date.now() - last;
      timer = setTimeout(flush, Math.max(0, 16 - elapsed));
    };

    (async () => {
      try {
        const sub = await c.event.subscribe(undefined, { signal: controller.signal });
        let yielded = Date.now();

        for await (const raw of sub.stream) {
          if (cancelled) break;

          const event = normalizeEvent(raw);
          if (!event) continue;

          const key = keyForEvent(event);
          if (key) {
            const existing = coalesced.get(key);
            if (existing !== undefined) {
              queue[existing] = undefined;
            }
            coalesced.set(key, queue.length);
          }

          queue.push(event);
          schedule();

          if (Date.now() - yielded < 8) continue;
          yielded = Date.now();
          await new Promise<void>((resolve) => setTimeout(resolve, 0));
        }
      } catch (e) {
        if (cancelled) return;

        const message = e instanceof Error ? e.message : String(e);
        if (message.toLowerCase().includes("abort")) return;
        options.setError(message);
      }
    })();

    onCleanup(() => {
      cancelled = true;
      controller.abort();
      flush();
    });
  });

  return {
    sessions,
    sessionStatusById,
    selectedSession,
    selectedSessionStatus,
    messages,
    todos,
    pendingPermissions,
    permissionReplyBusy,
    events,
    activePermission,
    loadSessions,
    refreshPendingPermissions,
    selectSession,
    renameSession,
    respondPermission,
    setSessions,
    setSessionStatusById,
    setMessages,
    setTodos,
    setPendingPermissions,
  };
}



================================================
FILE: packages/app/src/app/context/sync.tsx
================================================
import { createContext, useContext, type ParentProps } from "solid-js";
import type { SetStoreFunction, Store } from "solid-js/store";

import { useGlobalSync, type WorkspaceState } from "./global-sync";

type SyncContextValue = {
  directory: string;
  data: Store<WorkspaceState>;
  set: SetStoreFunction<WorkspaceState>;
};

const SyncContext = createContext<SyncContextValue | undefined>(undefined);

export function SyncProvider(props: ParentProps & { directory: string }) {
  const globalSync = useGlobalSync();
  const [store, setStore] = globalSync.child(props.directory);

  const value: SyncContextValue = {
    directory: props.directory,
    data: store,
    set: setStore,
  };

  return <SyncContext.Provider value={value}>{props.children}</SyncContext.Provider>;
}

export function useSync() {
  const context = useContext(SyncContext);
  if (!context) {
    throw new Error("Sync context is missing");
  }
  return context;
}



================================================
FILE: packages/app/src/app/context/updater.ts
================================================
import { createSignal } from "solid-js";

import type { UpdateHandle } from "../types";
import type { UpdaterEnvironment } from "../lib/tauri";

export type UpdateStatus =
  | { state: "idle"; lastCheckedAt: number | null }
  | { state: "checking"; startedAt: number }
  | { state: "available"; lastCheckedAt: number; version: string; date?: string; notes?: string }
  | {
      state: "downloading";
      lastCheckedAt: number;
      version: string;
      totalBytes: number | null;
      downloadedBytes: number;
      notes?: string;
    }
  | { state: "ready"; lastCheckedAt: number; version: string; notes?: string }
  | { state: "error"; lastCheckedAt: number | null; message: string };

export type PendingUpdate = { update: UpdateHandle; version: string; notes?: string } | null;

export function createUpdaterState() {
  const [updateAutoCheck, setUpdateAutoCheck] = createSignal(true);
  const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({ state: "idle", lastCheckedAt: null });
  const [pendingUpdate, setPendingUpdate] = createSignal<PendingUpdate>(null);
  const [updateEnv, setUpdateEnv] = createSignal<UpdaterEnvironment | null>(null);

  return {
    updateAutoCheck,
    setUpdateAutoCheck,
    updateStatus,
    setUpdateStatus,
    pendingUpdate,
    setPendingUpdate,
    updateEnv,
    setUpdateEnv,
  } as const;
}



================================================
FILE: packages/app/src/app/context/workspace.ts
================================================
import { createMemo, createSignal } from "solid-js";

import type {
  Client,
  Mode,
  OnboardingStep,
  WorkspaceDisplay,
  WorkspaceOpenworkConfig,
  WorkspacePreset,
} from "../types";
import { addOpencodeCacheHint, isTauriRuntime, safeStringify, writeModePreference } from "../utils";
import { unwrap } from "../lib/opencode";
import {
  createOpenworkServerClient,
  normalizeOpenworkServerUrl,
  OpenworkServerError,
  type OpenworkServerSettings,
  type OpenworkWorkspaceInfo,
} from "../lib/openwork-server";
import { downloadDir, homeDir } from "@tauri-apps/api/path";
import {
  engineDoctor,
  engineInfo,
  engineInstall,
  engineStart,
  engineStop,
  pickFile,
  pickDirectory,
  saveFile,
  workspaceBootstrap,
  workspaceCreate,
  workspaceCreateRemote,
  workspaceExportConfig,
  workspaceForget,
  workspaceImportConfig,
  workspaceOpenworkRead,
  workspaceOpenworkWrite,
  workspaceSetActive,
  workspaceUpdateRemote,
  type EngineDoctorResult,
  type EngineInfo,
  type WorkspaceInfo,
} from "../lib/tauri";
import { waitForHealthy, createClient } from "../lib/opencode";
import type { ProviderListItem } from "../types";
import { t, currentLocale } from "../../i18n";
import { mapConfigProvidersToList } from "../utils/providers";

export type WorkspaceStore = ReturnType<typeof createWorkspaceStore>;

export function createWorkspaceStore(options: {
  mode: () => Mode | null;
  setMode: (mode: Mode | null) => void;
  onboardingStep: () => OnboardingStep;
  setOnboardingStep: (step: OnboardingStep) => void;
  rememberModeChoice: () => boolean;
  baseUrl: () => string;
  setBaseUrl: (value: string) => void;
  clientDirectory: () => string;
  setClientDirectory: (value: string) => void;
  client: () => Client | null;
  setClient: (value: Client | null) => void;
  setConnectedVersion: (value: string | null) => void;
  setSseConnected: (value: boolean) => void;
  setProviders: (value: ProviderListItem[]) => void;
  setProviderDefaults: (value: Record<string, string>) => void;
  setProviderConnectedIds: (value: string[]) => void;
  setError: (value: string | null) => void;
  setBusy: (value: boolean) => void;
  setBusyLabel: (value: string | null) => void;
  setBusyStartedAt: (value: number | null) => void;
  loadCommands: (options?: { workspaceRoot?: string; quiet?: boolean }) => Promise<void>;
  loadSessions: (scopeRoot?: string) => Promise<void>;
  refreshPendingPermissions: () => Promise<void>;
  selectedSessionId: () => string | null;
  selectSession: (id: string) => Promise<void>;
  setSelectedSessionId: (value: string | null) => void;
  setMessages: (value: any[]) => void;
  setTodos: (value: any[]) => void;
  setPendingPermissions: (value: any[]) => void;
  setSessionStatusById: (value: Record<string, string>) => void;
  defaultModel: () => any;
  modelVariant: () => string | null;
  refreshSkills: (options?: { force?: boolean }) => Promise<void>;
  refreshPlugins: () => Promise<void>;
  engineSource: () => "path" | "sidecar";
  setEngineSource: (value: "path" | "sidecar") => void;
  setView: (value: any) => void;
  setTab: (value: any) => void;
  isWindowsPlatform: () => boolean;
  openworkServerSettings: () => OpenworkServerSettings;
  updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
  onEngineStable?: () => void;
}) {

  const [engine, setEngine] = createSignal<EngineInfo | null>(null);
  const [engineDoctorResult, setEngineDoctorResult] = createSignal<EngineDoctorResult | null>(null);
  const [engineDoctorCheckedAt, setEngineDoctorCheckedAt] = createSignal<number | null>(null);
  const [engineInstallLogs, setEngineInstallLogs] = createSignal<string | null>(null);

  const [projectDir, setProjectDir] = createSignal("");
  const [workspaces, setWorkspaces] = createSignal<WorkspaceInfo[]>([]);
  const [activeWorkspaceId, setActiveWorkspaceId] = createSignal<string>("starter");

  const syncActiveWorkspaceId = (id: string) => {
    setActiveWorkspaceId(id);
  };

  const [authorizedDirs, setAuthorizedDirs] = createSignal<string[]>([]);
  const [newAuthorizedDir, setNewAuthorizedDir] = createSignal("");

  const [workspaceConfig, setWorkspaceConfig] = createSignal<WorkspaceOpenworkConfig | null>(null);
  const [workspaceConfigLoaded, setWorkspaceConfigLoaded] = createSignal(false);
  const [workspaceSearch, setWorkspaceSearch] = createSignal("");
  const [workspacePickerOpen, setWorkspacePickerOpen] = createSignal(false);
  const [createWorkspaceOpen, setCreateWorkspaceOpen] = createSignal(false);
  const [createRemoteWorkspaceOpen, setCreateRemoteWorkspaceOpen] = createSignal(false);
  const [connectingWorkspaceId, setConnectingWorkspaceId] = createSignal<string | null>(null);
  const [exportingWorkspaceConfig, setExportingWorkspaceConfig] = createSignal(false);
  const [importingWorkspaceConfig, setImportingWorkspaceConfig] = createSignal(false);

  const activeWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === activeWorkspaceId()) ?? null);
  const activeWorkspaceDisplay = createMemo<WorkspaceDisplay>(() => {
    const ws = activeWorkspaceInfo();
    if (!ws) {
      return {
        id: "",
        name: "Workspace",
        path: "",
        preset: "starter",
        workspaceType: "local",
        remoteType: "opencode",
        baseUrl: null,
        directory: null,
        displayName: null,
        openworkHostUrl: null,
        openworkWorkspaceId: null,
        openworkWorkspaceName: null,
      };
    }
    const displayName =
      ws.displayName?.trim() ||
      ws.openworkWorkspaceName?.trim() ||
      ws.name ||
      ws.openworkHostUrl ||
      ws.baseUrl ||
      ws.path ||
      "Workspace";
    return { ...ws, name: displayName };
  });
  const normalizeRemoteType = (value?: WorkspaceInfo["remoteType"] | null) =>
    value === "openwork" ? "openwork" : "opencode";
  const isOpenworkRemote = (workspace: WorkspaceInfo | null) =>
    Boolean(workspace && workspace.workspaceType === "remote" && normalizeRemoteType(workspace.remoteType) === "openwork");
  const activeWorkspacePath = createMemo(() => {
    const ws = activeWorkspaceInfo();
    if (!ws) return "";
    if (ws.workspaceType === "remote") return ws.directory?.trim() ?? "";
    return ws.path ?? "";
  });
  const activeWorkspaceRoot = createMemo(() => activeWorkspacePath().trim());
  const filteredWorkspaces = createMemo(() => {
    const query = workspaceSearch().trim().toLowerCase();
    if (!query) return workspaces();
    return workspaces().filter((ws) => {
      const haystack = `${ws.name ?? ""} ${ws.path ?? ""} ${ws.baseUrl ?? ""} ${
        ws.displayName ?? ""
      } ${ws.directory ?? ""} ${ws.openworkHostUrl ?? ""} ${ws.openworkWorkspaceName ?? ""}`.toLowerCase();
      return haystack.includes(query);
    });
  });

  const resolveOpenworkHost = async (input: { hostUrl: string; token?: string | null }) => {
    const normalized = normalizeOpenworkServerUrl(input.hostUrl) ?? "";
    if (!normalized) {
      return { kind: "fallback" as const };
    }

    const client = createOpenworkServerClient({ baseUrl: normalized, token: input.token ?? undefined });

    try {
      const health = await client.health();
      if (!health?.ok) {
        return { kind: "fallback" as const };
      }
    } catch (error) {
      if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) {
        throw error;
      }
      return { kind: "fallback" as const };
    }

    if (!input.token?.trim()) {
      throw new Error("Access token required for OpenWork host.");
    }

    const response = await client.listWorkspaces();
    const items = Array.isArray(response.items) ? response.items : [];
    const workspace = items[0] as OpenworkWorkspaceInfo | undefined;
    if (!workspace) {
      throw new Error("OpenWork host did not return a workspace.");
    }
    let opencodeBaseUrl = workspace.opencode?.baseUrl?.trim() ?? workspace.baseUrl?.trim() ?? "";
    if (!opencodeBaseUrl) {
      throw new Error("OpenWork host did not provide an OpenCode URL.");
    }

    try {
      const hostUrl = new URL(normalized);
      const opencodeUrl = new URL(opencodeBaseUrl);
      if (hostUrl.hostname && opencodeUrl.hostname !== hostUrl.hostname) {
        opencodeUrl.hostname = hostUrl.hostname;
        opencodeUrl.protocol = hostUrl.protocol;
        opencodeBaseUrl = opencodeUrl.toString();
      }
    } catch {
      // ignore
    }

    return {
      kind: "openwork" as const,
      hostUrl: normalized,
      workspace,
      opencodeBaseUrl,
      directory: workspace.opencode?.directory?.trim() ?? workspace.directory?.trim() ?? "",
    };
  };

  async function refreshEngine() {
    if (!isTauriRuntime()) return;

    try {
      const info = await engineInfo();
      setEngine(info);

      if (info.projectDir) {
        setProjectDir(info.projectDir);
      }
      if (info.baseUrl) {
        options.setBaseUrl(info.baseUrl);
      }
    } catch {
      // ignore
    }
  }

  async function refreshEngineDoctor() {
    if (!isTauriRuntime()) return;

    try {
      const result = await engineDoctor({ preferSidecar: options.engineSource() === "sidecar" });
      setEngineDoctorResult(result);
      setEngineDoctorCheckedAt(Date.now());
    } catch (e) {
      setEngineDoctorResult(null);
      setEngineDoctorCheckedAt(Date.now());
      setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e));
    }
  }

  async function activateWorkspace(workspaceId: string) {
    const id = workspaceId.trim();
    if (!id) return false;

    const next = workspaces().find((w) => w.id === id) ?? null;
    if (!next) return false;
    const isRemote = next.workspaceType === "remote";
    console.log("[workspace] activate", { id: next.id, type: next.workspaceType });

    const remoteType = isRemote ? normalizeRemoteType(next.remoteType) : "opencode";
    const baseUrl = isRemote ? next.baseUrl?.trim() ?? "" : "";

    setConnectingWorkspaceId(id);

    try {
      if (isRemote) {
        options.setMode("client");

        if (remoteType === "openwork") {
          const hostUrl = next.openworkHostUrl?.trim() ?? "";
          if (!hostUrl) {
            options.setError("OpenWork host URL is required.");
            return false;
          }

          const currentSettings = options.openworkServerSettings();
          if (currentSettings.urlOverride?.trim() !== hostUrl) {
            options.updateOpenworkServerSettings({
              ...currentSettings,
              urlOverride: hostUrl,
            });
          }

          let resolvedBaseUrl = baseUrl;
          let resolvedDirectory = next.directory?.trim() ?? "";
          let workspaceInfo: OpenworkWorkspaceInfo | null = null;

          try {
            const resolved = await resolveOpenworkHost({
              hostUrl,
              token: options.openworkServerSettings().token ?? undefined,
            });
            if (resolved.kind === "openwork") {
              resolvedBaseUrl = resolved.opencodeBaseUrl;
              resolvedDirectory = resolved.directory;
              workspaceInfo = resolved.workspace;
            } else {
              resolvedBaseUrl = baseUrl || hostUrl;
            }
          } catch (error) {
            const message = error instanceof Error ? error.message : safeStringify(error);
            options.setError(addOpencodeCacheHint(message));
            return false;
          }

          if (!resolvedBaseUrl) {
            options.setError(t("app.error.remote_base_url_required", currentLocale()));
            return false;
          }

          const ok = await connectToServer(resolvedBaseUrl, resolvedDirectory || undefined, {
            workspaceId: next.id,
            workspaceType: next.workspaceType,
            targetRoot: resolvedDirectory ?? "",
          });

          if (!ok) {
            return false;
          }

          if (isTauriRuntime()) {
            try {
              const ws = await workspaceUpdateRemote({
                workspaceId: next.id,
                remoteType: "openwork",
                baseUrl: resolvedBaseUrl,
                directory: resolvedDirectory || null,
                openworkHostUrl: hostUrl,
                openworkWorkspaceId: workspaceInfo?.id ?? next.openworkWorkspaceId ?? null,
                openworkWorkspaceName: workspaceInfo?.name ?? next.openworkWorkspaceName ?? null,
              });
              setWorkspaces(ws.workspaces);
              syncActiveWorkspaceId(ws.activeId);
            } catch {
              // ignore
            }
          }

          syncActiveWorkspaceId(id);
          setProjectDir(resolvedDirectory || "");
          setWorkspaceConfig(null);
          setWorkspaceConfigLoaded(true);
          setAuthorizedDirs([]);

          if (isTauriRuntime()) {
            try {
              await workspaceSetActive(id);
            } catch {
              // ignore
            }
          }

          return true;
        }

        if (!baseUrl) {
          options.setError(t("app.error.remote_base_url_required", currentLocale()));
          return false;
        }

        const ok = await connectToServer(baseUrl, next.directory?.trim() || undefined, {
          workspaceId: next.id,
          workspaceType: next.workspaceType,
          targetRoot: next.directory?.trim() ?? "",
        });

        if (!ok) {
          return false;
        }

        syncActiveWorkspaceId(id);
        setProjectDir(next.directory?.trim() ?? "");
        setWorkspaceConfig(null);
        setWorkspaceConfigLoaded(true);
        setAuthorizedDirs([]);

        if (isTauriRuntime()) {
          try {
            await workspaceSetActive(id);
          } catch {
            // ignore
          }
        }

        return true;
      }

    const wasHostMode = options.mode() === "host" && options.client();
    const nextRoot = isRemote ? next.directory?.trim() ?? "" : next.path;
    const oldWorkspacePath = projectDir();
    const workspaceChanged = oldWorkspacePath !== nextRoot;

    syncActiveWorkspaceId(id);
    setProjectDir(nextRoot);

    if (isTauriRuntime()) {
      if (isRemote) {
        setWorkspaceConfig(null);
        setWorkspaceConfigLoaded(true);
        setAuthorizedDirs([]);
      } else {
        setWorkspaceConfigLoaded(false);
        try {
          const cfg = await workspaceOpenworkRead({ workspacePath: next.path });
          setWorkspaceConfig(cfg);
          setWorkspaceConfigLoaded(true);

          const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : [];
          if (roots.length) {
            setAuthorizedDirs(roots);
          } else {
            setAuthorizedDirs([next.path]);
          }
        } catch {
          setWorkspaceConfig(null);
          setWorkspaceConfigLoaded(true);
          setAuthorizedDirs([next.path]);
        }
      }

      try {
        await workspaceSetActive(id);
      } catch {
        // ignore
      }
    } else if (!isRemote) {
      if (!authorizedDirs().includes(next.path)) {
        const merged = authorizedDirs().length ? authorizedDirs().slice() : [];
        if (!merged.includes(next.path)) merged.push(next.path);
        setAuthorizedDirs(merged);
      }
    } else {
      setAuthorizedDirs([]);
    }

    if (!isRemote) {
      await options.loadCommands({ workspaceRoot: next.path }).catch(() => undefined);
    }

    if (!isRemote && workspaceChanged && options.client() && !wasHostMode) {
      options.setSelectedSessionId(null);
      options.setMessages([]);
      options.setTodos([]);
      options.setPendingPermissions([]);
      options.setSessionStatusById({});
      await options.loadSessions(next.path).catch(() => undefined);
    }

    // In Host mode, restart the engine when workspace changes
    if (!isRemote && wasHostMode && workspaceChanged) {
      options.setError(null);
      options.setBusy(true);
      options.setBusyLabel("status.restarting_engine");
      options.setBusyStartedAt(Date.now());

      try {
        // Stop the current engine
        const info = await engineStop();
        setEngine(info);

        // Start engine with new workspace directory
        const newInfo = await engineStart(next.path, { preferSidecar: options.engineSource() === "sidecar" });
        setEngine(newInfo);

        // Reconnect to server
        if (newInfo.baseUrl) {
          const ok = await connectToServer(newInfo.baseUrl, newInfo.projectDir ?? undefined);
          if (!ok) {
            options.setError("Failed to reconnect after workspace switch");
          }
        }
      } 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);
      }
    }

      options.refreshSkills({ force: true }).catch(() => undefined);
      options.refreshPlugins().catch(() => undefined);
      return true;
    } finally {
      setConnectingWorkspaceId(null);
    }
  }

  async function connectToServer(
    nextBaseUrl: string,
    directory?: string,
    context?: {
      workspaceId?: string;
      workspaceType?: WorkspaceInfo["workspaceType"];
      targetRoot?: string;
    }
  ) {
    console.log("[workspace] connect", {
      baseUrl: nextBaseUrl,
      directory: directory ?? null,
      workspaceType: context?.workspaceType ?? null,
    });
    options.setError(null);
    options.setBusy(true);
    options.setBusyLabel("status.connecting");
    options.setBusyStartedAt(Date.now());
    options.setSseConnected(false);

    try {
      let resolvedDirectory = directory?.trim() ?? "";
      let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined);
      const health = await waitForHealthy(nextClient, { timeoutMs: 12_000 });

      if (context?.workspaceType === "remote" && !resolvedDirectory) {
        try {
          const pathInfo = unwrap(await nextClient.path.get());
          const discovered = pathInfo.directory?.trim() ?? "";
          if (discovered) {
            resolvedDirectory = discovered;
            console.log("[workspace] remote directory resolved", resolvedDirectory);
            if (isTauriRuntime() && context.workspaceId) {
              const updated = await workspaceUpdateRemote({
                workspaceId: context.workspaceId,
                directory: resolvedDirectory,
              });
              setWorkspaces(updated.workspaces);
              syncActiveWorkspaceId(updated.activeId);
            }
            setProjectDir(resolvedDirectory);
            nextClient = createClient(nextBaseUrl, resolvedDirectory);
          }
        } catch (error) {
          console.log("[workspace] remote directory lookup failed", error);
        }
      }

      options.setClient(nextClient);
      options.setConnectedVersion(health.version);
      options.setBaseUrl(nextBaseUrl);
      options.setClientDirectory(resolvedDirectory);

      const targetRoot = context?.targetRoot ?? (resolvedDirectory || activeWorkspaceRoot().trim());
      await options.loadSessions(targetRoot);
      await options.refreshPendingPermissions();

      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([]);
        }
      }

      options.setSelectedSessionId(null);
      options.setMessages([]);
      options.setTodos([]);
      options.setPendingPermissions([]);
      options.setSessionStatusById({});

      // Load workspace commands for all workspace types (local and remote)
      if (targetRoot) {
        await options
          .loadCommands({ workspaceRoot: targetRoot, quiet: true })
          .catch(() => undefined);
      }

      options.refreshSkills({ force: true }).catch(() => undefined);
      options.refreshPlugins().catch(() => undefined);
      if (!options.selectedSessionId()) {
        options.setTab("home");
        options.setView("session");
      }

      // If the user successfully connected, treat onboarding as complete so we
      // don't force the onboarding flow on subsequent launches.
      markOnboardingComplete();
      options.onEngineStable?.();
      return true;
    } catch (e) {
      options.setClient(null);
      options.setConnectedVersion(null);
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
      return false;
    } finally {
      options.setBusy(false);
      options.setBusyLabel(null);
      options.setBusyStartedAt(null);
    }
  }

  async function createWorkspaceFlow(preset: WorkspacePreset, folder: string | null) {
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return;
    }

    if (!folder) {
      options.setError(t("app.error.choose_folder", currentLocale()));
      return;
    }

    options.setBusy(true);
    options.setBusyLabel("status.creating_workspace");
    options.setBusyStartedAt(Date.now());
    options.setError(null);

    try {
      const resolvedFolder = await resolveWorkspacePath(folder);
      if (!resolvedFolder) {
        options.setError(t("app.error.choose_folder", currentLocale()));
        return;
      }

      const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Workspace";
      const ws = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
      setWorkspaces(ws.workspaces);
      syncActiveWorkspaceId(ws.activeId);

      const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
        if (active) {
          setProjectDir(active.path);
          setAuthorizedDirs([active.path]);
          await options.loadCommands({ workspaceRoot: active.path, quiet: true }).catch(() => undefined);
        }

      setWorkspacePickerOpen(false);
      setCreateWorkspaceOpen(false);
      options.setTab("home");
      options.setView("dashboard");
      markOnboardingComplete();
    } 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 createRemoteWorkspaceFlow(input: {
    openworkHostUrl?: string | null;
    openworkToken?: string | null;
    opencodeBaseUrl?: string | null;
    directory?: string | null;
    displayName?: string | null;
  }) {
    const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? "";
    const token = input.openworkToken?.trim() ?? "";
    const directBaseUrl = input.opencodeBaseUrl?.trim() ?? "";
    const directory = input.directory?.trim() ?? "";
    const displayName = input.displayName?.trim() || null;

    if (!hostUrl && !directBaseUrl) {
      options.setError("Add an OpenWork host URL or a direct OpenCode URL to continue.");
      return false;
    }

    options.setError(null);
    console.log("[workspace] create remote", {
      hostUrl: hostUrl || null,
      baseUrl: directBaseUrl || null,
      directory: directory || null,
      displayName,
    });

    options.setMode("client");

    let remoteType: "openwork" | "opencode" = "opencode";
    let resolvedBaseUrl = directBaseUrl;
    let resolvedDirectory = directory;
    let openworkWorkspace: OpenworkWorkspaceInfo | null = null;
    let resolvedHostUrl = hostUrl;

    if (hostUrl) {
      options.updateOpenworkServerSettings({
        ...options.openworkServerSettings(),
        urlOverride: hostUrl,
        token: token || undefined,
      });

      try {
        const resolved = await resolveOpenworkHost({ hostUrl, token });
        if (resolved.kind === "openwork") {
          remoteType = "openwork";
          resolvedBaseUrl = resolved.opencodeBaseUrl;
          resolvedDirectory = resolved.directory || directory;
          openworkWorkspace = resolved.workspace;
          resolvedHostUrl = resolved.hostUrl;
        } else if (!resolvedBaseUrl) {
          resolvedBaseUrl = hostUrl;
        }
      } catch (error) {
        const message = error instanceof Error ? error.message : safeStringify(error);
        options.setError(addOpencodeCacheHint(message));
        return false;
      }
    }

    if (!resolvedBaseUrl) {
      options.setError(t("app.error.remote_base_url_required", currentLocale()));
      return false;
    }

    const ok = await connectToServer(resolvedBaseUrl, resolvedDirectory || undefined, {
      workspaceType: "remote",
      targetRoot: resolvedDirectory ?? "",
    });

    if (!ok) {
      return false;
    }

    const finalDirectory = options.clientDirectory().trim() || resolvedDirectory || "";

    options.setBusy(true);
    options.setBusyLabel("status.creating_workspace");
    options.setBusyStartedAt(Date.now());

    try {
      if (isTauriRuntime()) {
        const ws = await workspaceCreateRemote({
          baseUrl: resolvedBaseUrl.replace(/\/+$/, ""),
          directory: finalDirectory ? finalDirectory : null,
          displayName,
          remoteType,
          openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null,
          openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null,
          openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null,
        });
        setWorkspaces(ws.workspaces);
        syncActiveWorkspaceId(ws.activeId);
      } else {
        const workspaceId = `remote:${resolvedBaseUrl}:${finalDirectory}`;
        const nextWorkspace: WorkspaceInfo = {
          id: workspaceId,
          name: displayName ?? openworkWorkspace?.name ?? resolvedHostUrl ?? resolvedBaseUrl,
          path: "",
          preset: "remote",
          workspaceType: "remote",
          remoteType,
          baseUrl: resolvedBaseUrl,
          directory: finalDirectory || null,
          displayName,
          openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null,
          openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null,
          openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null,
        };

        setWorkspaces((prev) => {
          const withoutMatch = prev.filter((workspace) => workspace.id !== workspaceId);
          return [...withoutMatch, nextWorkspace];
        });
        syncActiveWorkspaceId(workspaceId);
      }

      setProjectDir(finalDirectory);
      setWorkspaceConfig(null);
      setWorkspaceConfigLoaded(true);
      setAuthorizedDirs([]);

      setWorkspacePickerOpen(false);
      setCreateRemoteWorkspaceOpen(false);
      return true;
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
      return false;
    } finally {
      options.setBusy(false);
      options.setBusyLabel(null);
      options.setBusyStartedAt(null);
    }
  }

  async function forgetWorkspace(workspaceId: string) {
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return;
    }

    const id = workspaceId.trim();
    if (!id) return;

    console.log("[workspace] forget", { id });

    try {
      const previousActive = activeWorkspaceId();
      const ws = await workspaceForget(id);
      setWorkspaces(ws.workspaces);
      syncActiveWorkspaceId(ws.activeId);

      const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
      if (active) {
        setProjectDir(active.workspaceType === "remote" ? active.directory?.trim() ?? "" : active.path);
      }

      if (ws.activeId && ws.activeId !== previousActive) {
        await activateWorkspace(ws.activeId);
      }
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    }
  }

  async function pickWorkspaceFolder() {
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return null;
    }

    try {
      const selection = await pickDirectory({ title: t("onboarding.choose_workspace_folder", currentLocale()) });
      const folder =
        typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null;

      return folder ?? null;
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
      return null;
    }
  }

  async function exportWorkspaceConfig() {
    if (exportingWorkspaceConfig()) return;
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return;
    }

    const active = activeWorkspaceInfo();
    if (!active) {
      options.setError("Select a workspace to export");
      return;
    }
    if (active.workspaceType === "remote") {
      options.setError("Export is only supported for local workspaces");
      return;
    }

    setExportingWorkspaceConfig(true);
    options.setError(null);

    try {
      const nameBase = (active.displayName || active.name || "workspace")
        .toLowerCase()
        .replace(/[^a-z0-9-_]+/g, "-")
        .replace(/^-+|-+$/g, "")
        .slice(0, 60);
      const dateStamp = new Date().toISOString().slice(0, 10);
      const fileName = `openwork-${nameBase || "workspace"}-${dateStamp}.openwork-workspace`;
      const downloads = await downloadDir().catch(() => null);
      const defaultPath = downloads ? `${downloads}/${fileName}` : fileName;

      const outputPath = await saveFile({
        title: "Export workspace config",
        defaultPath,
        filters: [{ name: "OpenWork Workspace", extensions: ["openwork-workspace", "zip"] }],
      });

      if (!outputPath) {
        return;
      }

      await workspaceExportConfig({
        workspaceId: active.id,
        outputPath,
      });
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    } finally {
      setExportingWorkspaceConfig(false);
    }
  }

  async function importWorkspaceConfig() {
    if (importingWorkspaceConfig()) return;
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return;
    }

    setImportingWorkspaceConfig(true);
    options.setError(null);

    try {
      const selection = await pickFile({
        title: "Import workspace config",
        filters: [{ name: "OpenWork Workspace", extensions: ["openwork-workspace", "zip"] }],
      });
      const filePath =
        typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null;
      if (!filePath) return;

      const target = await pickDirectory({
        title: "Choose a workspace folder",
      });
      const folder =
        typeof target === "string" ? target : Array.isArray(target) ? target[0] : null;
      if (!folder) return;

      const resolvedFolder = await resolveWorkspacePath(folder);
      if (!resolvedFolder) {
        options.setError(t("app.error.choose_folder", currentLocale()));
        return;
      }

      const ws = await workspaceImportConfig({
        archivePath: filePath,
        targetDir: resolvedFolder,
      });

      setWorkspaces(ws.workspaces);
      syncActiveWorkspaceId(ws.activeId);
      setWorkspacePickerOpen(false);
      setCreateWorkspaceOpen(false);
      setCreateRemoteWorkspaceOpen(false);
      options.setTab("home");
      options.setView("dashboard");
      markOnboardingComplete();

      if (ws.activeId) {
        await activateWorkspace(ws.activeId);
      }
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    } finally {
      setImportingWorkspaceConfig(false);
    }
  }

  async function startHost(optionsOverride?: { workspacePath?: string }) {
    if (!isTauriRuntime()) {
      options.setError(t("app.error.tauri_required", currentLocale()));
      return false;
    }

    if (activeWorkspaceInfo()?.workspaceType === "remote") {
      options.setError(t("app.error.host_requires_local", currentLocale()));
      return false;
    }

    const dir = (optionsOverride?.workspacePath ?? activeWorkspacePath() ?? projectDir()).trim();
    if (!dir) {
      options.setError(t("app.error.pick_workspace_folder", currentLocale()));
      return false;
    }

    try {
      const result = await engineDoctor({ preferSidecar: options.engineSource() === "sidecar" });
      setEngineDoctorResult(result);
      setEngineDoctorCheckedAt(Date.now());

      if (!result.found) {
        options.setError(
          options.isWindowsPlatform()
            ? "OpenCode CLI not found. Install OpenCode for Windows or bundle opencode.exe with OpenWork, then restart. If it is installed, ensure `opencode.exe` is on PATH (try `opencode --version` in PowerShell)."
            : "OpenCode CLI not found. Install with `brew install anomalyco/tap/opencode` or `curl -fsSL https://opencode.ai/install | bash`, then retry.",
        );
        return false;
      }

      if (!result.supportsServe) {
        const serveDetails = [result.serveHelpStdout, result.serveHelpStderr]
          .filter((value) => value && value.trim())
          .join("\n\n");
        const suffix = serveDetails ? `\n\nServe output:\n${serveDetails}` : "";
        options.setError(
          `OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update OpenCode and retry.${suffix}`
        );
        return false;
      }
    } catch (e) {
      setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e));
    }

    options.setError(null);
    options.setBusy(true);
    options.setBusyLabel("status.starting_engine");
    options.setBusyStartedAt(Date.now());

    try {
      setProjectDir(dir);
      if (!authorizedDirs().length) {
        setAuthorizedDirs([dir]);
      }

      const info = await engineStart(dir, { preferSidecar: options.engineSource() === "sidecar" });
      setEngine(info);

      if (info.baseUrl) {
        const ok = await connectToServer(info.baseUrl, info.projectDir ?? undefined);
        if (!ok) return false;
      }

      markOnboardingComplete();
      return true;
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
      return false;
    } finally {
      options.setBusy(false);
      options.setBusyLabel(null);
      options.setBusyStartedAt(null);
    }
  }

  async function stopHost() {
    options.setError(null);
    options.setBusy(true);
    options.setBusyLabel("status.disconnecting");
    options.setBusyStartedAt(Date.now());

    try {
      if (isTauriRuntime()) {
        const info = await engineStop();
        setEngine(info);
      }

      options.setClient(null);
      options.setConnectedVersion(null);
      options.setSelectedSessionId(null);
      options.setMessages([]);
      options.setTodos([]);
      options.setPendingPermissions([]);
      options.setSessionStatusById({});
      options.setSseConnected(false);

      options.setMode(null);
      options.setOnboardingStep("mode");

      options.setView("session");
    } 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 reloadWorkspaceEngine() {
    if (!isTauriRuntime()) {
      options.setError("Reloading the engine requires the desktop app.");
      return false;
    }

    if (options.mode() !== "host") {
      options.setError("Reload is only available in Host mode.");
      return false;
    }

    const root = activeWorkspacePath().trim();
    if (!root) {
      options.setError("Pick a workspace folder first.");
      return false;
    }

    options.setError(null);
    options.setBusy(true);
    options.setBusyLabel("status.reloading_engine");
    options.setBusyStartedAt(Date.now());

    try {
      const info = await engineStop();
      setEngine(info);

      const nextInfo = await engineStart(root, { preferSidecar: options.engineSource() === "sidecar" });
      setEngine(nextInfo);

      if (nextInfo.baseUrl) {
        const ok = await connectToServer(nextInfo.baseUrl, nextInfo.projectDir ?? undefined);
        if (!ok) {
          options.setError("Failed to reconnect after reload");
          return false;
        }
      }

      return true;
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
      return false;
    } finally {
      options.setBusy(false);
      options.setBusyLabel(null);
      options.setBusyStartedAt(null);
    }
  }

  async function onInstallEngine() {
    options.setError(null);
    setEngineInstallLogs(null);
    options.setBusy(true);
    options.setBusyLabel("status.installing_opencode");
    options.setBusyStartedAt(Date.now());

    try {
      const result = await engineInstall();
      const combined = `${result.stdout}${result.stderr ? `\n${result.stderr}` : ""}`.trim();
      setEngineInstallLogs(combined || null);

      if (!result.ok) {
        options.setError(result.stderr.trim() || t("app.error.install_failed", currentLocale()));
      }

      await refreshEngineDoctor();
    } 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);
    }
  }

  function normalizeRoots(list: string[]) {
    const out: string[] = [];
    for (const entry of list) {
      const trimmed = entry.trim().replace(/\/+$/, "");
      if (!trimmed) continue;
      if (!out.includes(trimmed)) out.push(trimmed);
    }
    return out;
  }

  async function resolveWorkspacePath(input: string) {
    const trimmed = input.trim();
    if (!trimmed) return "";
    if (!isTauriRuntime()) return trimmed;

    if (trimmed === "~") {
      try {
        return (await homeDir()).replace(/[\\/]+$/, "");
      } catch {
        return trimmed;
      }
    }

    if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
      try {
        const home = (await homeDir()).replace(/[\\/]+$/, "");
        return `${home}${trimmed.slice(1)}`;
      } catch {
        return trimmed;
      }
    }

    return trimmed;
  }

  function markOnboardingComplete() {
    if (typeof window === "undefined") return;
    try {
      window.localStorage.setItem("openwork.onboardingComplete", "1");
    } catch {
      // ignore
    }
  }

  async function persistAuthorizedRoots(nextRoots: string[]) {
    if (!isTauriRuntime()) return;
    if (activeWorkspaceInfo()?.workspaceType === "remote") return;
    const root = activeWorkspacePath().trim();
    if (!root) return;

    const existing = workspaceConfig();
    const cfg: WorkspaceOpenworkConfig = {
      version: existing?.version ?? 1,
      workspace: existing?.workspace ?? null,
      authorizedRoots: nextRoots,
    };

    await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
    setWorkspaceConfig(cfg);
  }

  async function addAuthorizedDir() {
    if (activeWorkspaceInfo()?.workspaceType === "remote") return;
    const next = newAuthorizedDir().trim();
    if (!next) return;

    const roots = normalizeRoots([...authorizedDirs(), next]);
    setAuthorizedDirs(roots);
    setNewAuthorizedDir("");

    try {
      await persistAuthorizedRoots(roots);
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    }
  }

  async function addAuthorizedDirFromPicker(optionsOverride?: { persistToWorkspace?: boolean }) {
    if (!isTauriRuntime()) return;
    if (activeWorkspaceInfo()?.workspaceType === "remote") return;

    try {
      const selection = await pickDirectory({ title: t("onboarding.authorize_folder", currentLocale()) });
      const folder =
        typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null;
      if (!folder) return;

      const roots = normalizeRoots([...authorizedDirs(), folder]);
      setAuthorizedDirs(roots);

      if (optionsOverride?.persistToWorkspace) {
        await persistAuthorizedRoots(roots);
      }
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    }
  }

  async function removeAuthorizedDir(dir: string) {
    if (activeWorkspaceInfo()?.workspaceType === "remote") return;
    const roots = normalizeRoots(authorizedDirs().filter((root) => root !== dir));
    setAuthorizedDirs(roots);

    try {
      await persistAuthorizedRoots(roots);
    } catch (e) {
      const message = e instanceof Error ? e.message : safeStringify(e);
      options.setError(addOpencodeCacheHint(message));
    }
  }

  function removeAuthorizedDirAtIndex(index: number) {
    const roots = authorizedDirs();
    const target = roots[index];
    if (target) {
      void removeAuthorizedDir(target);
    }
  }

  async function bootstrapOnboarding() {
    const modePref = (() => {
      try {
        return window.localStorage.getItem("openwork.modePref") as Mode | null;
      } catch {
        return null;
      }
    })();
    const onboardingComplete = (() => {
      try {
        return window.localStorage.getItem("openwork.onboardingComplete") === "1";
      } catch {
        return false;
      }
    })();

    if (isTauriRuntime()) {
      try {
        const ws = await workspaceBootstrap();
        setWorkspaces(ws.workspaces);
        syncActiveWorkspaceId(ws.activeId);
      } catch {
        // ignore
      }
    }

    await refreshEngine();
    await refreshEngineDoctor();

    if (isTauriRuntime()) {
      const active = workspaces().find((w) => w.id === activeWorkspaceId()) ?? null;
      if (active) {
        if (active.workspaceType === "remote") {
          setProjectDir(active.directory?.trim() ?? "");
          setWorkspaceConfig(null);
          setWorkspaceConfigLoaded(true);
          setAuthorizedDirs([]);
          if (active.baseUrl) {
            options.setBaseUrl(active.baseUrl);
          }
        } else {
          setProjectDir(active.path);
          try {
            const cfg = await workspaceOpenworkRead({ workspacePath: active.path });
            setWorkspaceConfig(cfg);
            setWorkspaceConfigLoaded(true);
            const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : [];
            setAuthorizedDirs(roots.length ? roots : [active.path]);
          } catch {
            setWorkspaceConfig(null);
            setWorkspaceConfigLoaded(true);
            setAuthorizedDirs([active.path]);
          }

          await options
            .loadCommands({ workspaceRoot: active.path, quiet: true })
            .catch(() => undefined);
        }
      }
    }

    const info = engine();
    if (info?.baseUrl) {
      options.setBaseUrl(info.baseUrl);
    }

    const activeWorkspace = activeWorkspaceInfo();
    if (activeWorkspace?.workspaceType === "remote") {
      options.setMode("client");
      options.setOnboardingStep("connecting");
      const ok = await activateWorkspace(activeWorkspace.id);
      if (!ok) {
        options.setOnboardingStep("client");
      }
      return;
    }

    if (!modePref && activeWorkspacePath().trim()) {
      options.setMode("host");

      if (info?.running && info.baseUrl) {
        options.setOnboardingStep("connecting");
        const ok = await connectToServer(info.baseUrl, info.projectDir ?? undefined);
        if (!ok) {
          options.setMode(null);
          options.setOnboardingStep("mode");
          return;
        }
        markOnboardingComplete();
        return;
      }

      options.setOnboardingStep("connecting");
      const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
      if (!ok) {
        options.setOnboardingStep("host");
        return;
      }
      markOnboardingComplete();
      return;
    }

    if (!modePref) return;

    if (modePref === "host") {
      options.setMode("host");

      if (info?.running && info.baseUrl) {
        options.setOnboardingStep("connecting");
        const ok = await connectToServer(info.baseUrl, info.projectDir ?? undefined);
        if (!ok) {
          options.setMode(null);
          options.setOnboardingStep("mode");
        }
        return;
      }

      if (isTauriRuntime() && activeWorkspacePath().trim()) {
        if (!authorizedDirs().length && activeWorkspacePath().trim()) {
          setAuthorizedDirs([activeWorkspacePath().trim()]);
        }

        options.setOnboardingStep("connecting");
        const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
        if (!ok) {
          options.setOnboardingStep("host");
        }
        return;
      }

      options.setOnboardingStep("host");
      return;
    }

    options.setMode("client");
    if (!options.baseUrl().trim()) {
      options.setOnboardingStep("client");
      return;
    }

    options.setOnboardingStep("connecting");
    const ok = await connectToServer(
      options.baseUrl().trim(),
      options.clientDirectory().trim() ? options.clientDirectory().trim() : undefined,
    );

    if (!ok) {
      options.setOnboardingStep("client");
    }
  }

  function onModeSelect(nextMode: Mode) {
    if (nextMode === "host" && options.rememberModeChoice()) {
      writeModePreference("host");
    }
    if (nextMode === "client" && options.rememberModeChoice()) {
      writeModePreference("client");
    }
    options.setMode(nextMode);
    options.setOnboardingStep(nextMode === "host" ? "host" : "client");
  }

  function onBackToMode() {
    options.setMode(null);
    options.setOnboardingStep("mode");
  }

  async function onStartHost() {
    options.setMode("host");
    options.setOnboardingStep("connecting");
    const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
    if (!ok) {
      options.setOnboardingStep("host");
    }
  }

  async function onAttachHost() {
    options.setMode("host");
    options.setOnboardingStep("connecting");
    const ok = await connectToServer(engine()?.baseUrl ?? "", engine()?.projectDir ?? undefined);
    if (!ok) {
      options.setMode(null);
      options.setOnboardingStep("mode");
    }
  }

  async function onConnectClient() {
    options.setMode("client");
    options.setOnboardingStep("connecting");
    const settings = options.openworkServerSettings();
    const ok = await createRemoteWorkspaceFlow({
      openworkHostUrl: settings.urlOverride ?? null,
      openworkToken: settings.token ?? null,
      opencodeBaseUrl: options.baseUrl().trim(),
      directory: options.clientDirectory().trim() ? options.clientDirectory().trim() : null,
      displayName: null,
    });
    if (!ok) {
      options.setOnboardingStep("client");
    }
  }

  function onRememberModeToggle() {
    if (typeof window === "undefined") return;
    const next = !options.rememberModeChoice();
    try {
      if (next) {
        const current = options.mode();
        if (current === "host" || current === "client") {
          writeModePreference(current);
        }
      } else {
        window.localStorage.removeItem("openwork.modePref");
      }
    } catch {
      // ignore
    }
  }

  return {
    engine,
    engineDoctorResult,
    engineDoctorCheckedAt,
    engineInstallLogs,
    projectDir,
    workspaces,
    activeWorkspaceId,
    authorizedDirs,
    newAuthorizedDir,
    workspaceConfig,
    workspaceConfigLoaded,
    workspaceSearch,
    workspacePickerOpen,
    createWorkspaceOpen,
    createRemoteWorkspaceOpen,
    connectingWorkspaceId,
    exportingWorkspaceConfig,
    importingWorkspaceConfig,
    activeWorkspaceDisplay,
    activeWorkspacePath,
    activeWorkspaceRoot,
    filteredWorkspaces,
    setWorkspaceSearch,
    setWorkspacePickerOpen,
    setCreateWorkspaceOpen,
    setCreateRemoteWorkspaceOpen,
    setProjectDir,
    setAuthorizedDirs,
    setNewAuthorizedDir,
    setWorkspaceConfig,
    setWorkspaceConfigLoaded,
    setWorkspaces,
    syncActiveWorkspaceId: syncActiveWorkspaceId,
    refreshEngine,
    refreshEngineDoctor,
    activateWorkspace,
    connectToServer,
    createWorkspaceFlow,
    createRemoteWorkspaceFlow,
    forgetWorkspace,
    pickWorkspaceFolder,
    exportWorkspaceConfig,
    importWorkspaceConfig,
    startHost,
    stopHost,
    reloadWorkspaceEngine,
    bootstrapOnboarding,
    onModeSelect,
    onBackToMode,
    onStartHost,
    onAttachHost,
    onConnectClient,
    onRememberModeToggle,
    onInstallEngine,
    addAuthorizedDir,
    addAuthorizedDirFromPicker,
    removeAuthorizedDir,
    removeAuthorizedDirAtIndex,
    setEngineInstallLogs,
  };
}



================================================
FILE: packages/app/src/app/data/skill-creator.md
================================================
---
name: skill-creator
description: Guide for creating effective skills. Use when users want to create or update a skill that extends OpenCode with specialized knowledge, workflows, or tool integrations.
---

# Skill Creator

This skill is a template + checklist for creating skills in a workspace.

## What is a skill?

A skill is a folder under `.opencode/skills/<skill-name>/` or `.claude/skills/<skill-name>/` anchored by `SKILL.md`.

## Design goals

- Portable: safe to copy between machines
- Reconstructable: can recreate any required local state
- Self-building: can bootstrap its own config/state
- Credential-safe: no secrets committed; graceful first-time setup

## Recommended structure

```
.opencode/
  skills/
    my-skill/
      SKILL.md
      README.md
      templates/
      scripts/
```

## Authoring checklist

1. Start with a clear purpose statement: when to use it + what it outputs.
2. Specify inputs/outputs and any required permissions.
3. Include “Setup” steps if the skill needs local tooling.
4. Add examples: at least 2 realistic user prompts.
5. Keep it safe: avoid destructive defaults; ask for confirmation.



================================================
FILE: packages/app/src/app/lib/opencode.ts
================================================
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";

type FieldsResult<T> =
  | ({ data: T; error?: undefined } & { request: Request; response: Response })
  | ({ data?: undefined; error: unknown } & { request: Request; response: Response });

export function unwrap<T>(result: FieldsResult<T>): NonNullable<T> {
  if (result.data !== undefined) {
    return result.data as NonNullable<T>;
  }
  const message =
    result.error instanceof Error
      ? result.error.message
      : typeof result.error === "string"
        ? result.error
        : JSON.stringify(result.error);
  throw new Error(message || "Unknown error");
}

export function createClient(baseUrl: string, directory?: string) {
  return createOpencodeClient({
    baseUrl,
    directory,
  });
}

export async function waitForHealthy(
  client: ReturnType<typeof createClient>,
  options?: { timeoutMs?: number; pollMs?: number },
) {
  const timeoutMs = options?.timeoutMs ?? 10_000;
  const pollMs = options?.pollMs ?? 250;

  const start = Date.now();
  let lastError: string | null = null;

  while (Date.now() - start < timeoutMs) {
    try {
      const health = unwrap(await client.global.health());
      if (health.healthy) {
        return health;
      }
      lastError = "Server reported unhealthy";
    } catch (error) {
      lastError = error instanceof Error ? error.message : "Unknown error";
    }
    await new Promise((resolve) => setTimeout(resolve, pollMs));
  }

  throw new Error(lastError ?? "Timed out waiting for server health");
}



================================================
FILE: packages/app/src/app/lib/openwork-server.ts
================================================
export type OpenworkServerCapabilities = {
  skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
  plugins: { read: boolean; write: boolean };
  mcp: { read: boolean; write: boolean };
  commands: { read: boolean; write: boolean };
  config: { read: boolean; write: boolean };
};

export type OpenworkServerStatus = "connected" | "disconnected" | "limited";

export type OpenworkServerSettings = {
  urlOverride?: string;
  portOverride?: number;
  token?: string;
};

export type OpenworkWorkspaceInfo = {
  id: string;
  name: string;
  path: string;
  workspaceType: "local" | "remote";
  baseUrl?: string;
  directory?: string;
  opencode?: {
    baseUrl?: string;
    directory?: string;
  };
};

export type OpenworkPluginItem = {
  spec: string;
  source: "config" | "dir.project" | "dir.global";
  scope: "project" | "global";
  path?: string;
};

export type OpenworkSkillItem = {
  name: string;
  path: string;
  description: string;
  scope: "project" | "global";
};

export type OpenworkCommandItem = {
  name: string;
  description?: string;
  template: string;
  agent?: string;
  model?: string | null;
  subtask?: boolean;
  scope: "workspace" | "global";
};

export type OpenworkMcpItem = {
  name: string;
  config: Record<string, unknown>;
  source: "config.project" | "config.global" | "config.remote";
  disabledByTools?: boolean;
};

export type OpenworkActor = {
  type: "remote" | "host";
  clientId?: string;
  tokenHash?: string;
};

export type OpenworkAuditEntry = {
  id: string;
  workspaceId: string;
  actor: OpenworkActor;
  action: string;
  target: string;
  summary: string;
  timestamp: number;
};

export const DEFAULT_OPENWORK_SERVER_PORT = 8787;

const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride";
const STORAGE_PORT_OVERRIDE = "openwork.server.port";
const STORAGE_TOKEN = "openwork.server.token";

export function normalizeOpenworkServerUrl(input: string) {
  const trimmed = input.trim();
  if (!trimmed) return null;
  const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
  return withProtocol.replace(/\/+$/, "");
}

export function readOpenworkServerSettings(): OpenworkServerSettings {
  if (typeof window === "undefined") return {};
  try {
    const urlOverride = normalizeOpenworkServerUrl(
      window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "",
    );
    const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? "";
    const portOverride = portRaw ? Number(portRaw) : undefined;
    const token = window.localStorage.getItem(STORAGE_TOKEN) ?? undefined;
    return {
      urlOverride: urlOverride ?? undefined,
      portOverride: Number.isNaN(portOverride) ? undefined : portOverride,
      token: token?.trim() || undefined,
    };
  } catch {
    return {};
  }
}

export function writeOpenworkServerSettings(next: OpenworkServerSettings): OpenworkServerSettings {
  if (typeof window === "undefined") return next;
  try {
    const urlOverride = normalizeOpenworkServerUrl(next.urlOverride ?? "");
    const portOverride = typeof next.portOverride === "number" ? next.portOverride : undefined;
    const token = next.token?.trim() || undefined;

    if (urlOverride) {
      window.localStorage.setItem(STORAGE_URL_OVERRIDE, urlOverride);
    } else {
      window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
    }

    if (typeof portOverride === "number" && !Number.isNaN(portOverride)) {
      window.localStorage.setItem(STORAGE_PORT_OVERRIDE, String(portOverride));
    } else {
      window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
    }

    if (token) {
      window.localStorage.setItem(STORAGE_TOKEN, token);
    } else {
      window.localStorage.removeItem(STORAGE_TOKEN);
    }

    return readOpenworkServerSettings();
  } catch {
    return next;
  }
}

export function clearOpenworkServerSettings() {
  if (typeof window === "undefined") return;
  try {
    window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
    window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
    window.localStorage.removeItem(STORAGE_TOKEN);
  } catch {
    // ignore
  }
}

export function deriveOpenworkServerUrl(
  opencodeBaseUrl: string,
  settings?: OpenworkServerSettings,
) {
  const override = settings?.urlOverride?.trim();
  if (override) {
    return normalizeOpenworkServerUrl(override);
  }

  const base = opencodeBaseUrl.trim();
  if (!base) return null;
  try {
    const url = new URL(base);
    const port = settings?.portOverride ?? DEFAULT_OPENWORK_SERVER_PORT;
    url.port = String(port);
    url.pathname = "";
    url.search = "";
    url.hash = "";
    return url.origin;
  } catch {
    return null;
  }
}

export class OpenworkServerError extends Error {
  status: number;
  code: string;
  details?: unknown;

  constructor(status: number, code: string, message: string, details?: unknown) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

function buildHeaders(
  token?: string,
  hostToken?: string,
  extra?: Record<string, string>,
) {
  const headers: Record<string, string> = { "Content-Type": "application/json" };
  if (token) {
    headers.Authorization = `Bearer ${token}`;
  }
  if (hostToken) {
    headers["X-OpenWork-Host-Token"] = hostToken;
  }
  if (extra) {
    Object.assign(headers, extra);
  }
  return headers;
}

async function requestJson<T>(
  baseUrl: string,
  path: string,
  options: { method?: string; token?: string; hostToken?: string; body?: unknown } = {},
): Promise<T> {
  const url = `${baseUrl}${path}`;
  const response = await fetch(url, {
    method: options.method ?? "GET",
    headers: buildHeaders(options.token, options.hostToken),
    body: options.body ? JSON.stringify(options.body) : undefined,
  });

  const text = await response.text();
  const json = text ? JSON.parse(text) : null;

  if (!response.ok) {
    const code = typeof json?.code === "string" ? json.code : "request_failed";
    const message = typeof json?.message === "string" ? json.message : response.statusText;
    throw new OpenworkServerError(response.status, code, message, json?.details);
  }

  return json as T;
}

export function createOpenworkServerClient(options: { baseUrl: string; token?: string; hostToken?: string }) {
  const baseUrl = options.baseUrl.replace(/\/+$/, "");
  const token = options.token;
  const hostToken = options.hostToken;

  return {
    baseUrl,
    token,
    health: () => requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health"),
    capabilities: () => requestJson<OpenworkServerCapabilities>(baseUrl, "/capabilities", { token, hostToken }),
    listWorkspaces: () => requestJson<{ items: OpenworkWorkspaceInfo[] }>(baseUrl, "/workspaces", { token, hostToken }),
    getConfig: (workspaceId: string) =>
      requestJson<{ opencode: Record<string, unknown>; openwork: Record<string, unknown>; updatedAt?: number | null }>(
        baseUrl,
        `/workspace/${workspaceId}/config`,
        { token, hostToken },
      ),
    patchConfig: (workspaceId: string, payload: { opencode?: Record<string, unknown>; openwork?: Record<string, unknown> }) =>
      requestJson<{ updatedAt?: number | null }>(baseUrl, `/workspace/${workspaceId}/config`, {
        token,
        hostToken,
        method: "PATCH",
        body: payload,
      }),
    listPlugins: (workspaceId: string, options?: { includeGlobal?: boolean }) => {
      const query = options?.includeGlobal ? "?includeGlobal=true" : "";
      return requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
        baseUrl,
        `/workspace/${workspaceId}/plugins${query}`,
        { token, hostToken },
      );
    },
    addPlugin: (workspaceId: string, spec: string) =>
      requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
        baseUrl,
        `/workspace/${workspaceId}/plugins`,
        { token, hostToken, method: "POST", body: { spec } },
      ),
    removePlugin: (workspaceId: string, name: string) =>
      requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
        baseUrl,
        `/workspace/${workspaceId}/plugins/${encodeURIComponent(name)}`,
        { token, hostToken, method: "DELETE" },
      ),
    listSkills: (workspaceId: string, options?: { includeGlobal?: boolean }) => {
      const query = options?.includeGlobal ? "?includeGlobal=true" : "";
      return requestJson<{ items: OpenworkSkillItem[] }>(
        baseUrl,
        `/workspace/${workspaceId}/skills${query}`,
        { token, hostToken },
      );
    },
    upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) =>
      requestJson<OpenworkSkillItem>(baseUrl, `/workspace/${workspaceId}/skills`, {
        token,
        hostToken,
        method: "POST",
        body: payload,
      }),
    listMcp: (workspaceId: string) =>
      requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, { token, hostToken }),
    addMcp: (workspaceId: string, payload: { name: string; config: Record<string, unknown> }) =>
      requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, {
        token,
        hostToken,
        method: "POST",
        body: payload,
      }),
    removeMcp: (workspaceId: string, name: string) =>
      requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}`, {
        token,
        hostToken,
        method: "DELETE",
      }),
    listCommands: (workspaceId: string, scope: "workspace" | "global" = "workspace") =>
      requestJson<{ items: OpenworkCommandItem[] }>(
        baseUrl,
        `/workspace/${workspaceId}/commands?scope=${scope}`,
        { token, hostToken },
      ),
    listAudit: (workspaceId: string, limit = 50) =>
      requestJson<{ items: OpenworkAuditEntry[] }>(
        baseUrl,
        `/workspace/${workspaceId}/audit?limit=${limit}`,
        { token, hostToken },
      ),
    upsertCommand: (
      workspaceId: string,
      payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
    ) =>
      requestJson<{ items: OpenworkCommandItem[] }>(baseUrl, `/workspace/${workspaceId}/commands`, {
        token,
        hostToken,
        method: "POST",
        body: payload,
      }),
    deleteCommand: (workspaceId: string, name: string) =>
      requestJson<{ ok: boolean }>(baseUrl, `/workspace/${workspaceId}/commands/${encodeURIComponent(name)}`, {
        token,
        hostToken,
        method: "DELETE",
      }),
  };
}

export type OpenworkServerClient = ReturnType<typeof createOpenworkServerClient>;



================================================
FILE: packages/app/src/app/lib/tauri.ts
================================================
import { invoke } from "@tauri-apps/api/core";
import { validateMcpServerName } from "../mcp";

export type EngineInfo = {
  running: boolean;
  baseUrl: string | null;
  projectDir: string | null;
  hostname: string | null;
  port: number | null;
  pid: number | null;
  lastStdout: string | null;
  lastStderr: string | null;
};

export type OpenworkServerInfo = {
  running: boolean;
  host: string | null;
  port: number | null;
  baseUrl: string | null;
  connectUrl: string | null;
  mdnsUrl: string | null;
  lanUrl: string | null;
  clientToken: string | null;
  hostToken: string | null;
  pid: number | null;
  lastStdout: string | null;
  lastStderr: string | null;
};

export type EngineDoctorResult = {
  found: boolean;
  inPath: boolean;
  resolvedPath: string | null;
  version: string | null;
  supportsServe: boolean;
  notes: string[];
  serveHelpStatus: number | null;
  serveHelpStdout: string | null;
  serveHelpStderr: string | null;
};

export type WorkspaceInfo = {
  id: string;
  name: string;
  path: string;
  preset: string;
  workspaceType: "local" | "remote";
  remoteType?: "openwork" | "opencode" | null;
  baseUrl?: string | null;
  directory?: string | null;
  displayName?: string | null;
  openworkHostUrl?: string | null;
  openworkWorkspaceId?: string | null;
  openworkWorkspaceName?: string | null;
};

export type WorkspaceList = {
  activeId: string;
  workspaces: WorkspaceInfo[];
};

export type WorkspaceExportSummary = {
  outputPath: string;
  included: number;
  excluded: string[];
};

export async function engineStart(
  projectDir: string,
  options?: { preferSidecar?: boolean },
): Promise<EngineInfo> {
  return invoke<EngineInfo>("engine_start", {
    projectDir,
    preferSidecar: options?.preferSidecar ?? false,
  });
}

export async function workspaceBootstrap(): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_bootstrap");
}

export async function workspaceSetActive(workspaceId: string): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_set_active", { workspaceId });
}

export async function workspaceCreate(input: {
  folderPath: string;
  name: string;
  preset: string;
}): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_create", {
    folderPath: input.folderPath,
    name: input.name,
    preset: input.preset,
  });
}

export async function workspaceCreateRemote(input: {
  baseUrl: string;
  directory?: string | null;
  displayName?: string | null;
  remoteType?: "openwork" | "opencode" | null;
  openworkHostUrl?: string | null;
  openworkWorkspaceId?: string | null;
  openworkWorkspaceName?: string | null;
}): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_create_remote", {
    baseUrl: input.baseUrl,
    directory: input.directory ?? null,
    displayName: input.displayName ?? null,
    remoteType: input.remoteType ?? null,
    openworkHostUrl: input.openworkHostUrl ?? null,
    openworkWorkspaceId: input.openworkWorkspaceId ?? null,
    openworkWorkspaceName: input.openworkWorkspaceName ?? null,
  });
}

export async function workspaceUpdateRemote(input: {
  workspaceId: string;
  baseUrl?: string | null;
  directory?: string | null;
  displayName?: string | null;
  remoteType?: "openwork" | "opencode" | null;
  openworkHostUrl?: string | null;
  openworkWorkspaceId?: string | null;
  openworkWorkspaceName?: string | null;
}): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_update_remote", {
    workspaceId: input.workspaceId,
    baseUrl: input.baseUrl ?? null,
    directory: input.directory ?? null,
    displayName: input.displayName ?? null,
    remoteType: input.remoteType ?? null,
    openworkHostUrl: input.openworkHostUrl ?? null,
    openworkWorkspaceId: input.openworkWorkspaceId ?? null,
    openworkWorkspaceName: input.openworkWorkspaceName ?? null,
  });
}

export async function workspaceForget(workspaceId: string): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_forget", { workspaceId });
}

export async function workspaceAddAuthorizedRoot(input: {
  workspacePath: string;
  folderPath: string;
}): Promise<ExecResult> {
  return invoke<ExecResult>("workspace_add_authorized_root", {
    workspacePath: input.workspacePath,
    folderPath: input.folderPath,
  });
}

export async function workspaceExportConfig(input: {
  workspaceId: string;
  outputPath: string;
}): Promise<WorkspaceExportSummary> {
  return invoke<WorkspaceExportSummary>("workspace_export_config", {
    workspaceId: input.workspaceId,
    outputPath: input.outputPath,
  });
}

export async function workspaceImportConfig(input: {
  archivePath: string;
  targetDir: string;
  name?: string | null;
}): Promise<WorkspaceList> {
  return invoke<WorkspaceList>("workspace_import_config", {
    archivePath: input.archivePath,
    targetDir: input.targetDir,
    name: input.name ?? null,
  });
}

export type OpencodeCommandDraft = {
  name: string;
  description?: string;
  template: string;
  agent?: string;
  model?: string;
  subtask?: boolean;
};

export type WorkspaceOpenworkConfig = {
  version: number;
  workspace?: {
    name?: string | null;
    createdAt?: number | null;
    preset?: string | null;
  } | null;
  authorizedRoots: string[];
};

export async function workspaceOpenworkRead(input: {
  workspacePath: string;
}): Promise<WorkspaceOpenworkConfig> {
  return invoke<WorkspaceOpenworkConfig>("workspace_openwork_read", {
    workspacePath: input.workspacePath,
  });
}

export async function workspaceOpenworkWrite(input: {
  workspacePath: string;
  config: WorkspaceOpenworkConfig;
}): Promise<ExecResult> {
  return invoke<ExecResult>("workspace_openwork_write", {
    workspacePath: input.workspacePath,
    config: input.config,
  });
}

export async function opencodeCommandList(input: {
  scope: "workspace" | "global";
  projectDir: string;
}): Promise<string[]> {
  return invoke<string[]>("opencode_command_list", {
    scope: input.scope,
    projectDir: input.projectDir,
  });
}

export async function opencodeCommandWrite(input: {
  scope: "workspace" | "global";
  projectDir: string;
  command: OpencodeCommandDraft;
}): Promise<ExecResult> {
  return invoke<ExecResult>("opencode_command_write", {
    scope: input.scope,
    projectDir: input.projectDir,
    command: input.command,
  });
}

export async function opencodeCommandDelete(input: {
  scope: "workspace" | "global";
  projectDir: string;
  name: string;
}): Promise<ExecResult> {
  return invoke<ExecResult>("opencode_command_delete", {
    scope: input.scope,
    projectDir: input.projectDir,
    name: input.name,
  });
}

export async function engineStop(): Promise<EngineInfo> {
  return invoke<EngineInfo>("engine_stop");
}

export async function openworkServerInfo(): Promise<OpenworkServerInfo> {
  return invoke<OpenworkServerInfo>("openwork_server_info");
}

export async function engineInfo(): Promise<EngineInfo> {
  return invoke<EngineInfo>("engine_info");
}

export async function engineDoctor(options?: {
  preferSidecar?: boolean;
}): Promise<EngineDoctorResult> {
  return invoke<EngineDoctorResult>("engine_doctor", {
    preferSidecar: options?.preferSidecar ?? false,
  });
}

export async function pickDirectory(options?: {
  title?: string;
  defaultPath?: string;
  multiple?: boolean;
}): Promise<string | string[] | null> {
  const { open } = await import("@tauri-apps/plugin-dialog");
  return open({
    title: options?.title,
    defaultPath: options?.defaultPath,
    directory: true,
    multiple: options?.multiple,
  });
}

export async function pickFile(options?: {
  title?: string;
  defaultPath?: string;
  multiple?: boolean;
  filters?: Array<{ name: string; extensions: string[] }>;
}): Promise<string | string[] | null> {
  const { open } = await import("@tauri-apps/plugin-dialog");
  return open({
    title: options?.title,
    defaultPath: options?.defaultPath,
    directory: false,
    multiple: options?.multiple,
    filters: options?.filters,
  });
}

export async function saveFile(options?: {
  title?: string;
  defaultPath?: string;
  filters?: Array<{ name: string; extensions: string[] }>;
}): Promise<string | null> {
  const { save } = await import("@tauri-apps/plugin-dialog");
  return save({
    title: options?.title,
    defaultPath: options?.defaultPath,
    filters: options?.filters,
  });
}

export type ExecResult = {
  ok: boolean;
  status: number;
  stdout: string;
  stderr: string;
};

export async function engineInstall(): Promise<ExecResult> {
  return invoke<ExecResult>("engine_install");
}

export async function opkgInstall(projectDir: string, pkg: string): Promise<ExecResult> {
  return invoke<ExecResult>("opkg_install", { projectDir, package: pkg });
}

export async function importSkill(
  projectDir: string,
  sourceDir: string,
  options?: { overwrite?: boolean },
): Promise<ExecResult> {
  return invoke<ExecResult>("import_skill", {
    projectDir,
    sourceDir,
    overwrite: options?.overwrite ?? false,
  });
}

export async function installSkillTemplate(
  projectDir: string,
  name: string,
  content: string,
  options?: { overwrite?: boolean },
): Promise<ExecResult> {
  return invoke<ExecResult>("install_skill_template", {
    projectDir,
    name,
    content,
    overwrite: options?.overwrite ?? false,
  });
}

export type LocalSkillCard = {
  name: string;
  path: string;
  description?: string;
};

export async function listLocalSkills(projectDir: string): Promise<LocalSkillCard[]> {
  return invoke<LocalSkillCard[]>("list_local_skills", { projectDir });
}

export async function uninstallSkill(projectDir: string, name: string): Promise<ExecResult> {
  return invoke<ExecResult>("uninstall_skill", { projectDir, name });
}

export type OpencodeConfigFile = {
  path: string;
  exists: boolean;
  content: string | null;
};

export type UpdaterEnvironment = {
  supported: boolean;
  reason: string | null;
  executablePath: string | null;
  appBundlePath: string | null;
};

export async function updaterEnvironment(): Promise<UpdaterEnvironment> {
  return invoke<UpdaterEnvironment>("updater_environment");
}

export async function readOpencodeConfig(
  scope: "project" | "global",
  projectDir: string,
): Promise<OpencodeConfigFile> {
  return invoke<OpencodeConfigFile>("read_opencode_config", { scope, projectDir });
}

export async function writeOpencodeConfig(
  scope: "project" | "global",
  projectDir: string,
  content: string,
): Promise<ExecResult> {
  return invoke<ExecResult>("write_opencode_config", { scope, projectDir, content });
}

export async function resetOpenworkState(mode: "onboarding" | "all"): Promise<void> {
  return invoke<void>("reset_openwork_state", { mode });
}

export type CacheResetResult = {
  removed: string[];
  missing: string[];
  errors: string[];
};

export async function resetOpencodeCache(): Promise<CacheResetResult> {
  return invoke<CacheResetResult>("reset_opencode_cache");
}

// Owpenbot types
export type OwpenbotWhatsAppStatus = {
  linked: boolean;
  dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
  allowFrom: string[];
};

export type OwpenbotTelegramStatus = {
  configured: boolean;
  enabled: boolean;
};

export type OwpenbotOpencodeStatus = {
  url: string;
};

export type OwpenbotStatus = {
  running: boolean;
  config: string;
  whatsapp: OwpenbotWhatsAppStatus;
  telegram: OwpenbotTelegramStatus;
  opencode: OwpenbotOpencodeStatus;
};

export type OwpenbotInfo = {
  running: boolean;
  workspacePath: string | null;
  opencodeUrl: string | null;
  qrData: string | null;
  whatsappLinked: boolean;
  telegramConfigured: boolean;
  pid: number | null;
  lastStdout: string | null;
  lastStderr: string | null;
};

export type OwpenbotQr = {
  qr: string; // base64 encoded
  format: "png" | "ascii";
};

export type OwpenbotPairingRequest = {
  code: string;
  peerId: string;
  platform: "whatsapp" | "telegram";
  timestamp: number;
};

// Owpenbot functions - call Tauri commands that wrap owpenbot CLI
export async function getOwpenbotStatus(): Promise<OwpenbotStatus | null> {
  try {
    return await invoke<OwpenbotStatus>("owpenbot_status");
  } catch {
    return null;
  }
}

export async function owpenbotInfo(): Promise<OwpenbotInfo> {
  return invoke<OwpenbotInfo>("owpenbot_info");
}

export async function getOwpenbotQr(): Promise<OwpenbotQr | null> {
  try {
    const qrBase64 = await invoke<string>("owpenbot_qr");
    return {
      qr: qrBase64,
      format: "png",
    };
  } catch {
    return null;
  }
}

export async function setOwpenbotDmPolicy(
  policy: OwpenbotWhatsAppStatus["dmPolicy"],
): Promise<ExecResult> {
  try {
    await invoke("owpenbot_config_set", { key: "channels.whatsapp.dmPolicy", value: policy });
    return { ok: true, status: 0, stdout: "", stderr: "" };
  } catch (e) {
    return { ok: false, status: 1, stdout: "", stderr: String(e) };
  }
}

export async function setOwpenbotAllowlist(allowlist: string[]): Promise<ExecResult> {
  try {
    await invoke("owpenbot_config_set", {
      key: "channels.whatsapp.allowFrom",
      value: JSON.stringify(allowlist),
    });
    return { ok: true, status: 0, stdout: "", stderr: "" };
  } catch (e) {
    return { ok: false, status: 1, stdout: "", stderr: String(e) };
  }
}

export async function setOwpenbotTelegramToken(token: string): Promise<ExecResult> {
  try {
    await invoke("owpenbot_config_set", { key: "channels.telegram.token", value: token });
    return { ok: true, status: 0, stdout: "", stderr: "" };
  } catch (e) {
    return { ok: false, status: 1, stdout: "", stderr: String(e) };
  }
}

export async function getOwpenbotPairingRequests(): Promise<OwpenbotPairingRequest[]> {
  try {
    const result = await invoke<unknown>("owpenbot_pairing_list");
    const requests = Array.isArray(result) ? result : [];
    return requests
      .filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))
      .map((entry) => {
        const channel = String(entry.channel ?? "whatsapp");
        const createdAt = String(entry.createdAt ?? "");
        const platform: "whatsapp" | "telegram" = channel === "telegram" ? "telegram" : "whatsapp";
        return {
          code: String(entry.code ?? ""),
          peerId: String(entry.peerId ?? ""),
          platform,
          timestamp: createdAt ? Date.parse(createdAt) : Date.now(),
        };
      })
      .filter((entry) => entry.code && entry.peerId);
  } catch {
    return [];
  }
}

export async function approveOwpenbotPairing(code: string): Promise<ExecResult> {
  try {
    await invoke("owpenbot_pairing_approve", { code });
    return { ok: true, status: 0, stdout: "", stderr: "" };
  } catch (e) {
    return { ok: false, status: 1, stdout: "", stderr: String(e) };
  }
}

export async function denyOwpenbotPairing(code: string): Promise<ExecResult> {
  try {
    await invoke("owpenbot_pairing_deny", { code });
    return { ok: true, status: 0, stdout: "", stderr: "" };
  } catch (e) {
    return { ok: false, status: 1, stdout: "", stderr: String(e) };
  }
}

export async function opencodeMcpAuth(
  projectDir: string,
  serverName: string,
): Promise<ExecResult> {
  const safeProjectDir = projectDir.trim();
  if (!safeProjectDir) {
    throw new Error("project_dir is required");
  }

  const safeServerName = validateMcpServerName(serverName);

  return invoke<ExecResult>("opencode_mcp_auth", {
    projectDir: safeProjectDir,
    serverName: safeServerName,
  });
}



================================================
FILE: packages/app/src/app/pages/commands.tsx
================================================
import { createEffect, For, on, Show } from "solid-js";

import type { WorkspaceCommand } from "../types";
import { t, currentLocale } from "../../i18n";

import Button from "../components/button";
import { Play, Plus, Terminal, Trash2 } from "lucide-solid";

export type CommandsViewProps = {
  busy: boolean;
  workspaceCommands: WorkspaceCommand[];
  globalCommands: WorkspaceCommand[];
  otherCommands: WorkspaceCommand[];
  setCommandDraftName: (value: string) => void;
  setCommandDraftDescription: (value: string) => void;
  setCommandDraftTemplate: (value: string) => void;
  setCommandDraftScope: (value: "workspace" | "global") => void;
  openCommandModal: () => void;
  resetCommandDraft?: (scope?: "workspace" | "global") => void;
  runCommand: (command: WorkspaceCommand) => void;
  deleteCommand: (command: WorkspaceCommand) => void;
  justSavedCommand?: { name: string; scope: string } | null;
  clearJustSavedCommand?: () => void;
};

const commandNeedsDetails = (command: WorkspaceCommand) => /\$(ARGUMENTS|\d+)/i.test(command.template);

export default function CommandsView(props: CommandsViewProps) {
  const translate = (key: string) => t(key, currentLocale());

  // Map to store refs for command cards: "scope:name" -> HTMLElement
  const commandRefs = new Map<string, HTMLElement>();

  // Effect to scroll to and highlight the just-saved command
  createEffect(
    on(
      () => props.justSavedCommand,
      (saved) => {
        if (!saved) return;

        // Use requestAnimationFrame to ensure DOM is updated
        requestAnimationFrame(() => {
          const key = `${saved.scope}:${saved.name}`;
          const element = commandRefs.get(key);

          if (element) {
            // Smooth scroll to center the element
            element.scrollIntoView({ behavior: "smooth", block: "center" });

            // Add highlight animation class
            element.classList.add("command-just-saved");

            // Remove the class and clear state after animation completes
            setTimeout(() => {
              element.classList.remove("command-just-saved");
              props.clearJustSavedCommand?.();
            }, 2000);
          } else {
            // Element not found, just clear the state
            props.clearJustSavedCommand?.();
          }
        });
      },
    ),
  );

  const openNewCommand = () => {
    const reset = props.resetCommandDraft;
    if (reset) {
      reset("workspace");
    } else {
      props.setCommandDraftName("");
      props.setCommandDraftDescription("");
      props.setCommandDraftTemplate("");
      props.setCommandDraftScope("workspace");
    }
    props.openCommandModal();
  };

  const hasCommands = () =>
    props.workspaceCommands.length || props.globalCommands.length || props.otherCommands.length;

  const renderCommand = (command: WorkspaceCommand, tone: string) => (
    <div
      ref={(el) => commandRefs.set(`${command.scope}:${command.name}`, el)}
      class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 flex items-start justify-between gap-4 transition-all duration-300"
    >
      <div class="min-w-0">
        <div class="flex items-center gap-2">
          <Terminal size={16} class={tone} />
          <div class="font-medium text-gray-12 truncate">/{command.name}</div>
          <Show when={commandNeedsDetails(command)}>
            <span class="text-[10px] px-2 py-0.5 rounded-full bg-amber-7/20 text-amber-12 uppercase tracking-wide">
              {translate("commands.details_required")}
            </span>
          </Show>
        </div>
        <div class="mt-1 text-sm text-gray-10">
          {command.description || translate("commands.default_description")}
        </div>
      </div>
      <div class="shrink-0 flex gap-2">
        <Button variant="secondary" onClick={() => props.runCommand(command)} disabled={props.busy}>
          <Play size={16} />
          {translate("commands.run")}
        </Button>
        <Show when={command.scope !== "unknown"}>
          <Button variant="danger" onClick={() => props.deleteCommand(command)} disabled={props.busy}>
            <Trash2 size={16} />
          </Button>
        </Show>
      </div>
    </div>
  );

  return (
    <section class="space-y-4">
      <div class="flex items-center justify-between">
        <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">
          {translate("dashboard.commands")}
        </h3>
        <Button variant="secondary" onClick={openNewCommand} disabled={props.busy}>
          <Plus size={16} />
          {translate("commands.new")}
        </Button>
      </div>

      <Show
        when={hasCommands()}
        fallback={
          <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-6 text-sm text-gray-10">
            {translate("commands.empty_state")}
          </div>
        }
      >
        <div class="space-y-6">
          <Show when={props.workspaceCommands.length}>
            <div class="space-y-3">
              <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">
                {translate("commands.workspace")}
              </div>
              <For each={props.workspaceCommands}>
                {(command) => renderCommand(command, "text-indigo-11")}
              </For>
            </div>
          </Show>

          <Show when={props.globalCommands.length}>
            <div class="space-y-3">
              <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">
                {translate("commands.global")}
              </div>
              <For each={props.globalCommands}>
                {(command) => renderCommand(command, "text-green-11")}
              </For>
            </div>
          </Show>

          <Show when={props.otherCommands.length}>
            <div class="space-y-3">
              <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">
                {translate("commands.other")}
              </div>
              <For each={props.otherCommands}>
                {(command) => renderCommand(command, "text-gray-10")}
              </For>
            </div>
          </Show>
        </div>
      </Show>
    </section>
  );
}



================================================
FILE: packages/app/src/app/pages/dashboard.tsx
================================================
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type {
  DashboardTab,
  McpServerEntry,
  McpStatusMap,
  PluginScope,
  SettingsTab,
  SkillCard,
  WorkspaceCommand,
  View,
} from "../types";
import type { McpDirectoryInfo } from "../constants";
import { formatRelativeTime, normalizeDirectoryPath } from "../utils";
import type { OpenworkAuditEntry, OpenworkServerCapabilities, OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import type { EngineInfo, OpenworkServerInfo, OwpenbotInfo, WorkspaceInfo } from "../lib/tauri";

import Button from "../components/button";
import OpenWorkLogo from "../components/openwork-logo";
import WorkspaceChip from "../components/workspace-chip";
import McpView from "./mcp";
import PluginsView from "./plugins";
import SettingsView from "./settings";
import type { KeybindSetting } from "../components/settings-keybinds";
import SkillsView from "./skills";
import CommandsView from "./commands";
import StatusBar from "../components/status-bar";
import {
  Command,
  Cpu,
  Package,
  Play,
  Plus,
  Server,
  Terminal,
} from "lucide-solid";

export type DashboardViewProps = {
  tab: DashboardTab;
  setTab: (tab: DashboardTab) => void;
  settingsTab: SettingsTab;
  setSettingsTab: (tab: SettingsTab) => void;
  view: View;
  setView: (view: View, sessionId?: string) => void;
  mode: "host" | "client" | null;
  baseUrl: string;
  clientConnected: boolean;
  busy: boolean;
  busyHint: string | null;
  busyLabel: string | null;
  newTaskDisabled: boolean;
  headerStatus: string;
  error: string | null;
  openworkServerStatus: OpenworkServerStatus;
  openworkServerUrl: string;
  openworkServerSettings: OpenworkServerSettings;
  openworkServerHostInfo: OpenworkServerInfo | null;
  openworkServerCapabilities: OpenworkServerCapabilities | null;
  openworkServerWorkspaceId: string | null;
  openworkAuditEntries: OpenworkAuditEntry[];
  openworkAuditStatus: "idle" | "loading" | "error";
  openworkAuditError: string | null;
  engineInfo: EngineInfo | null;
  owpenbotInfo: OwpenbotInfo | null;
  updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
  resetOpenworkServerSettings: () => void;
  testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
  keybindItems: KeybindSetting[];
  onOverrideKeybind: (id: string, keybind: string | null) => void;
  onResetKeybind: (id: string) => void;
  onResetAllKeybinds: () => void;
  activeWorkspaceDisplay: WorkspaceInfo;
  workspaceSearch: string;
  setWorkspaceSearch: (value: string) => void;
  workspacePickerOpen: boolean;
  setWorkspacePickerOpen: (open: boolean) => void;
  connectingWorkspaceId: string | null;
  workspaces: WorkspaceInfo[];
  filteredWorkspaces: WorkspaceInfo[];
  activeWorkspaceId: string;
  activateWorkspace: (id: string) => Promise<boolean> | boolean;
  exportWorkspaceConfig: () => void;
  exportWorkspaceBusy: boolean;
  createWorkspaceOpen: boolean;
  setCreateWorkspaceOpen: (open: boolean) => void;
  createWorkspaceFlow: (
    preset: "starter" | "automation" | "minimal",
    folder: string | null
  ) => void;
  pickWorkspaceFolder: () => Promise<string | null>;
  sessions: Array<{
    id: string;
    slug?: string | null;
    title: string;
    time: { updated: number };
    directory?: string | null;
  }>;
  sessionStatusById: Record<string, string>;
  activeWorkspaceRoot: string;
  workspaceCommands: WorkspaceCommand[];
  globalCommands: WorkspaceCommand[];
  otherCommands: WorkspaceCommand[];
  setCommandDraftName: (value: string) => void;
  setCommandDraftDescription: (value: string) => void;
  setCommandDraftTemplate: (value: string) => void;
  setCommandDraftScope: (value: "workspace" | "global") => void;
  openCommandModal: () => void;
  resetCommandDraft?: (scope?: "workspace" | "global") => void;
  runCommand: (command: WorkspaceCommand) => void;
  deleteCommand: (command: WorkspaceCommand) => void;
  justSavedCommand?: { name: string; scope: string } | null;
  clearJustSavedCommand?: () => void;
  refreshSkills: (options?: { force?: boolean }) => void;
  refreshPlugins: (scopeOverride?: PluginScope) => void;
  refreshMcpServers: () => void;
  skills: SkillCard[];
  skillsStatus: string | null;
  skillsAccessHint?: string | null;
  canInstallSkillCreator: boolean;
  canUseDesktopTools: boolean;
  importLocalSkill: () => void;
  installSkillCreator: () => void;
  revealSkillsFolder: () => void;
  uninstallSkill: (name: string) => void;
  pluginsAccessHint?: string | null;
  canEditPlugins: boolean;
  canUseGlobalPluginScope: boolean;
  pluginScope: PluginScope;
  setPluginScope: (scope: PluginScope) => void;
  pluginConfigPath: string | null;
  pluginList: string[];
  pluginInput: string;
  setPluginInput: (value: string) => void;
  pluginStatus: string | null;
  activePluginGuide: string | null;
  setActivePluginGuide: (value: string | null) => void;
  isPluginInstalled: (name: string, aliases?: string[]) => boolean;
  suggestedPlugins: Array<{
    name: string;
    packageName: string;
    description: string;
    tags: string[];
    aliases?: string[];
    installMode?: "simple" | "guided";
    steps?: Array<{
      title: string;
      description: string;
      command?: string;
      url?: string;
      path?: string;
      note?: string;
    }>;
  }>;
  addPlugin: (pluginNameOverride?: string) => void;
  mcpServers: McpServerEntry[];
  mcpStatus: string | null;
  mcpLastUpdatedAt: number | null;
  mcpStatuses: McpStatusMap;
  mcpConnectingName: string | null;
  selectedMcp: string | null;
  setSelectedMcp: (value: string | null) => void;
  quickConnect: McpDirectoryInfo[];
  connectMcp: (entry: McpDirectoryInfo) => void;
  showMcpReloadBanner: boolean;
  mcpReloadBlocked: boolean;
  reloadMcpEngine: () => void;
  createSessionAndOpen: () => void;
  setPrompt: (value: string) => void;
  selectSession: (sessionId: string) => Promise<void> | void;
  defaultModelLabel: string;
  defaultModelRef: string;
  openDefaultModelPicker: () => void;
  showThinking: boolean;
  toggleShowThinking: () => void;
  modelVariantLabel: string;
  editModelVariant: () => void;
  updateAutoCheck: boolean;
  toggleUpdateAutoCheck: () => void;
  themeMode: "light" | "dark" | "system";
  setThemeMode: (value: "light" | "dark" | "system") => void;
  updateStatus: {
    state: string;
    lastCheckedAt?: number | null;
    version?: string;
    date?: string;
    notes?: string;
    totalBytes?: number | null;
    downloadedBytes?: number;
    message?: string;
  } | null;
  updateEnv: { supported?: boolean; reason?: string | null } | null;
  appVersion: string | null;
  checkForUpdates: () => void;
  downloadUpdate: () => void;
  installUpdateAndRestart: () => void;
  anyActiveRuns: boolean;
  engineSource: "path" | "sidecar";
  setEngineSource: (value: "path" | "sidecar") => void;
  isWindows: boolean;
  toggleDeveloperMode: () => void;
  developerMode: boolean;
  stopHost: () => void;
  openResetModal: (mode: "onboarding" | "all") => void;
  resetModalBusy: boolean;
  onResetStartupPreference: () => void;
  pendingPermissions: unknown;
  events: unknown;
  safeStringify: (value: unknown) => string;
  repairOpencodeCache: () => void;
  cacheRepairBusy: boolean;
  cacheRepairResult: string | null;
  notionStatus: "disconnected" | "connecting" | "connected" | "error";
  notionStatusDetail: string | null;
  notionError: string | null;
  notionBusy: boolean;
  connectNotion: () => void;
};

export default function DashboardView(props: DashboardViewProps) {
  const title = createMemo(() => {
    switch (props.tab) {
      case "sessions":
        return "Sessions";
      case "commands":
        return "Commands";
      case "skills":
        return "Skills";
      case "plugins":
        return "Plugins";
      case "mcp":
        return "MCPs";
      case "settings":
        return "Settings";
      default:
        return "Dashboard";
    }
  });

  const quickCommands = createMemo(() => props.workspaceCommands.slice(0, 3));
  const canExportWorkspace = createMemo(() => props.activeWorkspaceDisplay.workspaceType !== "remote");
  const workspaceDirectoryMap = createMemo(() =>
    props.workspaces.map((workspace) => ({
      id: workspace.id,
      name: workspace.displayName ?? workspace.name,
      path: normalizeDirectoryPath(
        workspace.workspaceType === "remote"
          ? workspace.directory ?? ""
          : workspace.path
      ),
    }))
  );

  const workspaceLabelForSession = (directory?: string | null) => {
    if (!directory) return null;
    const normalized = normalizeDirectoryPath(directory);
    if (!normalized) return null;
    return workspaceDirectoryMap().find((workspace) => workspace.path === normalized)?.name ?? null;
  };

  const openSessionFromList = (sessionId: string) => {
    // Defer view switch to avoid click-through on the same event frame.
    window.setTimeout(() => {
      void props.selectSession(sessionId);
      props.setTab("sessions");
      props.setView("session", sessionId);
    }, 0);
  };

  // Track last refreshed tab to avoid duplicate calls
  const [lastRefreshedTab, setLastRefreshedTab] = createSignal<string | null>(null);
  const [refreshInProgress, setRefreshInProgress] = createSignal(false);
  const [taskDraft, setTaskDraft] = createSignal("");

  const canCreateTask = createMemo(
    () => !props.newTaskDisabled && taskDraft().trim().length > 0
  );

  const startTask = () => {
    const value = taskDraft().trim();
    if (!value || props.newTaskDisabled) return;
    props.setPrompt(value);
    props.createSessionAndOpen();
    setTaskDraft("");
  };

  createEffect(() => {
    const currentTab = props.tab;

    // Skip if we already refreshed this tab or a refresh is in progress
    if (lastRefreshedTab() === currentTab || refreshInProgress()) {
      return;
    }

    // Track that we're refreshing this tab
    setRefreshInProgress(true);
    setLastRefreshedTab(currentTab);

    // Use a cancelled flag to prevent stale updates after navigation
    let cancelled = false;

    const doRefresh = async () => {
      try {
        if (currentTab === "skills" && !cancelled) {
          await props.refreshSkills();
        }
        if (currentTab === "plugins" && !cancelled) {
          await props.refreshPlugins();
        }
        if (currentTab === "mcp" && !cancelled) {
          await props.refreshMcpServers();
        }
        if (currentTab === "sessions" && !cancelled) {
          // Stagger these calls to avoid request stacking
          await props.refreshSkills();
          if (!cancelled) {
            await props.refreshPlugins("project");
          }
        }
      } catch {
        // Ignore errors during navigation
      } finally {
        if (!cancelled) {
          setRefreshInProgress(false);
        }
      }
    };

    doRefresh();

    onCleanup(() => {
      cancelled = true;
      setRefreshInProgress(false);
    });
  });

  const navItem = (t: DashboardTab, label: any, icon: any) => {
    const active = () => props.tab === t;
    return (
      <button
        class={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
          active()
            ? "bg-gray-2 text-gray-12"
            : "text-gray-10 hover:text-gray-12 hover:bg-gray-2/50"
        }`}
        onClick={() => props.setTab(t)}
      >
        {icon}
        {label}
      </button>
    );
  };

  const openSettings = (tab: SettingsTab = "general") => {
    props.setSettingsTab(tab);
    props.setTab("settings");
  };

  return (
    <div class="flex h-screen bg-gray-1 text-gray-12 overflow-hidden">
      <aside class="w-64 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
        <div>
            <div class="flex items-center gap-3 mb-10 px-2">
            <div class="">
              <OpenWorkLogo size={32} />
            </div>
            <span class="font-bold text-lg tracking-tight">OpenWork</span>
          </div>

          <nav class="space-y-1">
            {navItem("home", "Dashboard", <Command size={18} />)}
            {navItem("sessions", "Sessions", <Play size={18} />)}
            {navItem("commands", "Commands", <Terminal size={18} />)}
            {navItem("skills", "Skills", <Package size={18} />)}
            {navItem("plugins", "Plugins", <Cpu size={18} />)}
            {navItem(
              "mcp",
              <span class="inline-flex items-center gap-2">
                MCPs
                <span class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-7/20 text-amber-12">
                  Alpha
                </span>
              </span>,
              <Server size={18} />,
            )}
          </nav>
        </div>

        <div class="space-y-4">

          <Show when={props.mode === "client" && props.openworkServerStatus === "disconnected"}>
            <div class="text-[11px] text-gray-9 px-1">
              OpenWork server is offline — remote tasks still run.
            </div>
          </Show>

          <Show when={!props.clientConnected}>
            <Button
              variant="secondary"
              onClick={() => props.setWorkspacePickerOpen(true)}
              disabled={props.busy}
              class="w-full"
            >
              Connect folder
            </Button>
          </Show>
        </div>
      </aside>

      <main class="flex-1 overflow-y-auto relative pb-24 md:pb-12">
        <header class="h-16 flex items-center justify-between px-6 md:px-10 border-b border-gray-6 sticky top-0 bg-gray-1/80 backdrop-blur-md z-10">
          <div class="flex items-center gap-3">
            <WorkspaceChip
              workspace={props.activeWorkspaceDisplay}
              connecting={props.connectingWorkspaceId === props.activeWorkspaceDisplay.id}
              onClick={() => {
                props.setWorkspaceSearch("");
                props.setWorkspacePickerOpen(true);
              }}
            />
            <h1 class="text-lg font-medium">{title()}</h1>
            <Show when={props.developerMode}>
              <span class="text-xs text-gray-7">{props.headerStatus}</span>
            </Show>
            <Show when={props.busyHint}>
              <span class="text-xs text-gray-10">{props.busyHint}</span>
            </Show>
          </div>
          <div class="flex items-center gap-2">
            <Show when={props.tab === "home" || props.tab === "sessions"}>
              <Button
                variant="outline"
                class="text-xs h-9"
                onClick={props.exportWorkspaceConfig}
                disabled={!canExportWorkspace() || props.exportWorkspaceBusy}
                title={
                  !canExportWorkspace()
                    ? "Export is only available for local workspaces"
                    : "Export workspace config"
                }
              >
                Share config
              </Button>
              <Button
                onPointerDown={(e) => {
                  e.currentTarget.setPointerCapture?.(e.pointerId);
                }}
                onPointerUp={() => {
                  console.log("[DEBUG] new task button pointerup");
                  props.createSessionAndOpen();
                }}
                disabled={props.newTaskDisabled}
                title={props.newTaskDisabled ? props.busyHint ?? "Busy" : ""}
              >
                <Play size={16} />
                New Task
              </Button>
            </Show>

            <Show when={props.tab === "commands"}>
              <Button
                variant="secondary"
                onClick={() => {
                  const reset = props.resetCommandDraft;
                  if (reset) {
                    reset("workspace");
                  } else {
                    props.setCommandDraftName("");
                    props.setCommandDraftDescription("");
                    props.setCommandDraftTemplate("");
                    props.setCommandDraftScope("workspace");
                  }
                  props.openCommandModal();
                }}
                disabled={props.busy}
              >
                <Plus size={16} />
                New
              </Button>
            </Show>
          </div>
        </header>

        <div class="p-6 md:p-10 max-w-5xl mx-auto space-y-10">
          <Switch>
            <Match when={props.tab === "home"}>
              <section>
                <div class="bg-gradient-to-r from-gray-2 to-gray-4 rounded-3xl p-1 ">
                  <div class="bg-gray-1 rounded-[22px] p-6 md:p-8 flex flex-col md:flex-row items-center justify-between gap-6">
                    <div class="space-y-2 text-center md:text-left">
                      <h2 class="text-2xl font-semibold text-gray-12">
                        What should we do today?
                      </h2>
                      <p class="text-gray-11">
                        Describe an outcome. OpenWork will run it and keep an
                        audit trail.
                      </p>
                    </div>
                    <div class="w-full md:w-[360px]">
                      <div class="flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/50 px-4 py-3 shadow-lg shadow-gray-12/5 focus-within:border-gray-7 focus-within:bg-gray-2 transition-all">
                        <input
                          value={taskDraft()}
                          onInput={(event) => setTaskDraft(event.currentTarget.value)}
                          onKeyDown={(event) => {
                            if (event.key === "Enter" && !event.shiftKey) {
                              event.preventDefault();
                              startTask();
                            }
                          }}
                          placeholder="Draft a task to run..."
                          class="flex-1 bg-transparent border-none p-0 text-sm text-gray-12 placeholder-gray-7 focus:ring-0"
                          aria-label="Describe a task"
                          disabled={props.newTaskDisabled}
                        />
                        <button
                          type="button"
                          onClick={startTask}
                          disabled={!canCreateTask()}
                          class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1 shadow-md transition-all hover:scale-[1.02] active:scale-95 disabled:opacity-40 disabled:hover:scale-100"
                          title={
                            props.newTaskDisabled ? props.busyHint ?? "Busy" : ""
                          }
                        >
                          Run
                        </button>
                      </div>
                      <div class="mt-2 text-[11px] text-gray-9 text-center md:text-left">
                        Press Enter to start a new session.
                      </div>
                    </div>
                  </div>
                </div>
              </section>

              <section>
                <div class="flex items-center justify-between mb-4">
                  <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">
                    Quick Start Commands
                  </h3>
                  <button
                    class="text-sm text-gray-10 hover:text-gray-12"
                    onClick={() => props.setTab("commands")}
                  >
                    View all
                  </button>
                </div>

                <Show
                  when={quickCommands().length}
                  fallback={
                    <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-6 text-sm text-gray-10">
                      No commands yet. Starter commands will appear here.
                    </div>
                  }
                >
                  <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
                    <For each={quickCommands()}>
                      {(command) => (
                        <button
                          onClick={() => props.runCommand(command)}
                          class="group p-5 rounded-2xl bg-gray-2/30 border border-gray-6/50 hover:bg-gray-2 hover:border-gray-7 transition-all text-left"
                        >
                          <div class="w-10 h-10 rounded-full bg-gray-4 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
                            <Terminal size={20} class="text-indigo-11" />
                          </div>
                          <h4 class="font-medium text-gray-12 mb-1">/{command.name}</h4>
                          <p class="text-sm text-gray-10">
                            {command.description || "Run a saved command"}
                          </p>
                        </button>
                      )}
                    </For>
                  </div>
                </Show>
              </section>

              <section>
                <div class="flex items-center justify-between mb-4">
                  <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">
                    Workspaces
                  </h3>
                  <div class="flex items-center gap-2">
                    <Button
                      variant="outline"
                      class="text-xs h-8 px-3"
                      onClick={props.exportWorkspaceConfig}
                      disabled={!canExportWorkspace() || props.exportWorkspaceBusy}
                      title={
                        !canExportWorkspace()
                          ? "Export is only available for local workspaces"
                          : "Export workspace config"
                      }
                    >
                      Share config
                    </Button>
                    <Button
                      variant="secondary"
                      class="text-xs h-8 px-3"
                      onClick={() => props.setWorkspacePickerOpen(true)}
                    >
                      <Plus size={14} />
                      Add workspace
                    </Button>
                  </div>
                </div>

                <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                  <For each={props.workspaces}>
                    {(workspace) => (
                      <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
                        <div class="flex items-start justify-between">
                          <div class="space-y-1 min-w-0">
                            <div class="text-sm font-semibold text-gray-12 truncate">
                              {workspace.displayName ?? workspace.name}
                            </div>
                            <div class="text-xs text-gray-10 font-mono truncate">
                              {workspace.workspaceType === "remote"
                                ? workspace.baseUrl ?? workspace.path
                                : workspace.path}
                            </div>
                          </div>
                          <span class="text-[11px] text-gray-9">
                            {workspace.workspaceType === "remote" ? "Remote" : "Local"}
                          </span>
                        </div>
                        <div class="flex items-center justify-end text-xs text-gray-9 h-8">
                          <Show when={workspace.id === props.activeWorkspaceId}>
                            <span class="text-green-11 font-medium flex items-center gap-1.5 !px-2">
                              Active
                            </span>
                          </Show>
                          <Show when={workspace.id !== props.activeWorkspaceId}>
                            <Button
                              variant="ghost"
                              class="text-xs !px-2 py-1"
                              onClick={() => props.activateWorkspace(workspace.id)}
                              disabled={props.connectingWorkspaceId === workspace.id}
                            >
                              {props.connectingWorkspaceId === workspace.id
                                ? "Switching..."
                                : "Switch"}
                            </Button>
                          </Show>
                        </div>
                      </div>
                    )}
                  </For>
                </div>
              </section>

              <section>
                <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider mb-4">
                  Recent Sessions
                </h3>

                <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
                  <For each={props.sessions.slice(0, 3)}>
                    {(s, idx) => (
                      <button
                        class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
                          idx() !== Math.min(props.sessions.length, 3) - 1
                            ? "border-b border-gray-6/50"
                            : ""
                        }`}
                        onPointerDown={(e) => {
                          e.currentTarget.setPointerCapture?.(e.pointerId);
                        }}
                        onPointerUp={() => {
                          openSessionFromList(s.id);
                        }}
                      >
                        <div class="flex items-center gap-4">
                          <div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
                            #{s.slug?.slice(0, 2) ?? ".."}
                          </div>
                          <div>
                            <div class="font-medium text-sm text-gray-12">
                              {s.title}
                            </div>
                            <div class="text-xs text-gray-10 flex items-center gap-2">
                              <span class="flex items-center gap-1">
                                {formatRelativeTime(s.time.updated)}
                              </span>
                              <Show when={workspaceLabelForSession(s.directory)}>
                                {(label) => (
                                  <span class="text-[11px] px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-10">
                                    {label()}
                                  </span>
                                )}
                              </Show>
                            </div>
                          </div>
                        </div>
                        <div class="flex items-center gap-4">
                          <span class="text-xs px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-11 flex items-center gap-1.5">
                            <span class="w-1.5 h-1.5 rounded-full bg-current" />
                            {props.sessionStatusById[s.id] ?? "idle"}
                          </span>
                        </div>
                      </button>
                    )}
                  </For>

                  <Show when={!props.sessions.length}>
                    <div class="p-6 text-sm text-gray-10">
                      No sessions yet.
                    </div>
                  </Show>
                </div>
              </section>
            </Match>

            <Match when={props.tab === "sessions"}>
              <section>
                <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider mb-4">
                  Recent Sessions
                </h3>

                <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
                  <For each={props.sessions.slice(0, 3)}>
                    {(s, idx) => (
                      <button
                        class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
                          idx() !== Math.min(props.sessions.length, 3) - 1
                            ? "border-b border-gray-6/50"
                            : ""
                        }`}
                        onPointerDown={(e) => {
                          e.currentTarget.setPointerCapture?.(e.pointerId);
                        }}
                        onPointerUp={() => {
                          openSessionFromList(s.id);
                        }}
                      >
                        <div class="flex items-center gap-4">
                          <div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
                            #{s.slug?.slice(0, 2) ?? ".."}
                          </div>
                          <div>
                            <div class="font-medium text-sm text-gray-12">
                              {s.title}
                            </div>
                            <div class="text-xs text-gray-10 flex items-center gap-2">
                              <span class="flex items-center gap-1">
                                {formatRelativeTime(s.time.updated)}
                              </span>
                              <Show when={workspaceLabelForSession(s.directory)}>
                                {(label) => (
                                  <span class="text-[11px] px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-10">
                                    {label()}
                                  </span>
                                )}
                              </Show>
                            </div>
                          </div>
                        </div>
                        <div class="flex items-center gap-4">
                          <span class="text-xs px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-11 flex items-center gap-1.5">
                            <span class="w-1.5 h-1.5 rounded-full bg-current" />
                            {props.sessionStatusById[s.id] ?? "idle"}
                          </span>
                        </div>
                      </button>
                    )}
                  </For>

                  <Show when={!props.sessions.length}>
                    <div class="p-6 text-sm text-gray-10">
                      No sessions yet.
                    </div>
                  </Show>
                </div>
              </section>
            </Match>

            <Match when={props.tab === "commands"}>
              <CommandsView
                busy={props.busy}
                workspaceCommands={props.workspaceCommands}
                globalCommands={props.globalCommands}
                otherCommands={props.otherCommands}
                setCommandDraftName={props.setCommandDraftName}
                setCommandDraftDescription={props.setCommandDraftDescription}
                setCommandDraftTemplate={props.setCommandDraftTemplate}
                setCommandDraftScope={props.setCommandDraftScope}
                openCommandModal={props.openCommandModal}
                resetCommandDraft={props.resetCommandDraft}
                runCommand={props.runCommand}
                deleteCommand={props.deleteCommand}
                justSavedCommand={props.justSavedCommand}
                clearJustSavedCommand={props.clearJustSavedCommand}
              />
            </Match>

            <Match when={props.tab === "skills"}>
              <SkillsView
                busy={props.busy}
                mode={props.mode}
                canInstallSkillCreator={props.canInstallSkillCreator}
                canUseDesktopTools={props.canUseDesktopTools}
                accessHint={props.skillsAccessHint}
                refreshSkills={props.refreshSkills}
                skills={props.skills}
                skillsStatus={props.skillsStatus}
                importLocalSkill={props.importLocalSkill}
                installSkillCreator={props.installSkillCreator}
                revealSkillsFolder={props.revealSkillsFolder}
                uninstallSkill={props.uninstallSkill}
              />
            </Match>

            <Match when={props.tab === "plugins"}>
              <PluginsView
                busy={props.busy}
                activeWorkspaceRoot={props.activeWorkspaceRoot}
                canEditPlugins={props.canEditPlugins}
                canUseGlobalScope={props.canUseGlobalPluginScope}
                accessHint={props.pluginsAccessHint}
                pluginScope={props.pluginScope}
                setPluginScope={props.setPluginScope}
                pluginConfigPath={props.pluginConfigPath}
                pluginList={props.pluginList}
                pluginInput={props.pluginInput}
                setPluginInput={props.setPluginInput}
                pluginStatus={props.pluginStatus}
                activePluginGuide={props.activePluginGuide}
                setActivePluginGuide={props.setActivePluginGuide}
                isPluginInstalled={props.isPluginInstalled}
                suggestedPlugins={props.suggestedPlugins}
                refreshPlugins={props.refreshPlugins}
                addPlugin={props.addPlugin}
              />
            </Match>

            <Match when={props.tab === "mcp"}>
              <McpView
                mode={props.mode}
                busy={props.busy}
                activeWorkspaceRoot={props.activeWorkspaceRoot}
                mcpServers={props.mcpServers}
                mcpStatus={props.mcpStatus}
                mcpLastUpdatedAt={props.mcpLastUpdatedAt}
                mcpStatuses={props.mcpStatuses}
                mcpConnectingName={props.mcpConnectingName}
                selectedMcp={props.selectedMcp}
                setSelectedMcp={props.setSelectedMcp}
                quickConnect={props.quickConnect}
                connectMcp={props.connectMcp}
                showMcpReloadBanner={props.showMcpReloadBanner}
                reloadBlocked={props.mcpReloadBlocked}
                reloadMcpEngine={props.reloadMcpEngine}
              />
            </Match>

            <Match when={props.tab === "settings"}>
                <SettingsView
                  mode={props.mode}
                  baseUrl={props.baseUrl}
                  headerStatus={props.headerStatus}
                  busy={props.busy}
                  settingsTab={props.settingsTab}
                  setSettingsTab={props.setSettingsTab}
                  openworkServerStatus={props.openworkServerStatus}
                  openworkServerUrl={props.openworkServerUrl}
                  openworkServerSettings={props.openworkServerSettings}
                  openworkServerHostInfo={props.openworkServerHostInfo}
                  openworkServerCapabilities={props.openworkServerCapabilities}
                  openworkServerWorkspaceId={props.openworkServerWorkspaceId}
                  openworkAuditEntries={props.openworkAuditEntries}
                  openworkAuditStatus={props.openworkAuditStatus}
                  openworkAuditError={props.openworkAuditError}
                  engineInfo={props.engineInfo}
                  owpenbotInfo={props.owpenbotInfo}
                  updateOpenworkServerSettings={props.updateOpenworkServerSettings}
                  resetOpenworkServerSettings={props.resetOpenworkServerSettings}
                  testOpenworkServerConnection={props.testOpenworkServerConnection}
                  developerMode={props.developerMode}
                  toggleDeveloperMode={props.toggleDeveloperMode}
                  stopHost={props.stopHost}
                  engineSource={props.engineSource}
                  setEngineSource={props.setEngineSource}
                  isWindows={props.isWindows}
                  defaultModelLabel={props.defaultModelLabel}
                  defaultModelRef={props.defaultModelRef}
                  openDefaultModelPicker={props.openDefaultModelPicker}
                  showThinking={props.showThinking}
                  toggleShowThinking={props.toggleShowThinking}
                  modelVariantLabel={props.modelVariantLabel}
                  editModelVariant={props.editModelVariant}
                  keybindItems={props.keybindItems}
                  onOverrideKeybind={props.onOverrideKeybind}
                  onResetKeybind={props.onResetKeybind}
                  onResetAllKeybinds={props.onResetAllKeybinds}
                  updateAutoCheck={props.updateAutoCheck}
                  toggleUpdateAutoCheck={props.toggleUpdateAutoCheck}
                  themeMode={props.themeMode}
                  setThemeMode={props.setThemeMode}
                  updateStatus={props.updateStatus}
                  updateEnv={props.updateEnv}
                  appVersion={props.appVersion}
                  checkForUpdates={props.checkForUpdates}
                  downloadUpdate={props.downloadUpdate}
                  installUpdateAndRestart={props.installUpdateAndRestart}
                  anyActiveRuns={props.anyActiveRuns}
                  onResetStartupPreference={props.onResetStartupPreference}
                  openResetModal={props.openResetModal}
                  resetModalBusy={props.resetModalBusy}
                  pendingPermissions={props.pendingPermissions}
                  events={props.events}
                  safeStringify={props.safeStringify}
                  repairOpencodeCache={props.repairOpencodeCache}
                  cacheRepairBusy={props.cacheRepairBusy}
                  cacheRepairResult={props.cacheRepairResult}
                  notionStatus={props.notionStatus}
                  notionStatusDetail={props.notionStatusDetail}
                  notionError={props.notionError}
                  notionBusy={props.notionBusy}
                  connectNotion={props.connectNotion}
                />

            </Match>
          </Switch>
        </div>

        <Show when={props.error}>
          <div class="mx-auto max-w-5xl px-6 md:px-10 pb-24 md:pb-10">
            <div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 space-y-3">
              <div>{props.error}</div>
              <Show when={props.developerMode}>
                <div class="flex flex-wrap items-center gap-2">
                  <Button
                    variant="secondary"
                    class="text-xs h-8 py-0 px-3"
                    onClick={props.repairOpencodeCache}
                    disabled={props.cacheRepairBusy || !props.developerMode}
                  >
                    {props.cacheRepairBusy ? "Repairing cache" : "Repair cache"}
                  </Button>
                  <Button
                    variant="outline"
                    class="text-xs h-8 py-0 px-3"
                    onClick={props.stopHost}
                    disabled={props.busy}
                  >
                    Retry
                  </Button>
                  <Show when={props.cacheRepairResult}>
                    <span class="text-xs text-red-12/80">
                      {props.cacheRepairResult}
                    </span>
                  </Show>
                </div>
              </Show>
            </div>
          </div>
        </Show>

        <div class="fixed bottom-0 left-0 right-0">
          <StatusBar
            mode={props.mode}
            busy={props.busy}
            stopHost={props.stopHost}
            clientConnected={props.clientConnected}
            openworkServerStatus={props.openworkServerStatus}
            developerMode={props.developerMode}
            onOpenSettings={() => openSettings("general")}
            onOpenMessaging={() => openSettings("messaging")}
          />
          <nav class="md:hidden border-t border-gray-6 bg-gray-1/90 backdrop-blur-md">
            <div class="mx-auto max-w-5xl px-4 py-3 grid grid-cols-6 gap-2">
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "home" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("home")}
              >
                <Command size={18} />
                Home
              </button>
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "sessions" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("sessions")}
              >
                <Play size={18} />
                Runs
              </button>
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "commands" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("commands")}
              >
                <Terminal size={18} />
                Commands
              </button>
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "skills" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("skills")}
              >
                <Package size={18} />
                Skills
              </button>
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "plugins" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("plugins")}
              >
                <Cpu size={18} />
                Plugins
              </button>
              <button
                class={`flex flex-col items-center gap-1 text-xs ${
                  props.tab === "mcp" ? "text-gray-12" : "text-gray-10"
                }`}
                onClick={() => props.setTab("mcp")}
              >
                <Server size={18} />
                MCPs
              </button>
            </div>
          </nav>
        </div>
      </main>
    </div>
  );
}



================================================
FILE: packages/app/src/app/pages/mcp.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";

import type { McpServerEntry, McpStatusMap } from "../types";
import type { McpDirectoryInfo } from "../constants";
import { formatRelativeTime, isTauriRuntime, isWindowsPlatform } from "../utils";
import { readOpencodeConfig, type OpencodeConfigFile } from "../lib/tauri";

import Button from "../components/button";
import {
  CheckCircle2,
  CircleAlert,
  ChevronDown,
  ChevronRight,
  Copy,
  ExternalLink,
  FolderOpen,
  Loader2,
  PlugZap,
  RefreshCcw,
  Server,
  Settings,
} from "lucide-solid";
import TextInput from "../components/text-input";
import { currentLocale, t, type Language } from "../../i18n";

export type McpViewProps = {
  mode: "host" | "client" | null;
  busy: boolean;
  activeWorkspaceRoot: string;
  mcpServers: McpServerEntry[];
  mcpStatus: string | null;
  mcpLastUpdatedAt: number | null;
  mcpStatuses: McpStatusMap;
  mcpConnectingName: string | null;
  selectedMcp: string | null;
  setSelectedMcp: (name: string | null) => void;
  quickConnect: McpDirectoryInfo[];
  connectMcp: (entry: McpDirectoryInfo) => void;
  showMcpReloadBanner: boolean;
  reloadBlocked: boolean;
  reloadMcpEngine: () => void;
};

const statusBadge = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected") => {
  switch (status) {
    case "connected":
      return "bg-green-7/10 text-green-11 border-green-7/20";
    case "needs_auth":
    case "needs_client_registration":
      return "bg-amber-7/10 text-amber-11 border-amber-7/20";
    case "disabled":
      return "bg-gray-4/60 text-gray-11 border-gray-7/50";
    case "disconnected":
      return "bg-gray-2/80 text-gray-12 border-gray-7/50";
    default:
      return "bg-red-7/10 text-red-11 border-red-7/20";
  }
};

const statusLabel = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected", locale: Language) => {
  switch (status) {
    case "connected":
      return t("mcp.connected_label", locale);
    case "needs_auth":
      return t("mcp.needs_auth", locale);
    case "needs_client_registration":
      return t("mcp.register_client", locale);
    case "disabled":
      return t("mcp.status_disabled", locale);
    case "disconnected":
      return t("mcp.disconnected", locale);
    default:
      return t("mcp.failed", locale);
  }
};

export default function McpView(props: McpViewProps) {
  // Translation helper that uses current language from i18n
  const translate = (key: string) => t(key, currentLocale());
  const [showDangerousContent, setShowDangerousContent] = createSignal(true);

  const [configScope, setConfigScope] = createSignal<"project" | "global">("project");
  const [projectConfig, setProjectConfig] = createSignal<OpencodeConfigFile | null>(null);
  const [globalConfig, setGlobalConfig] = createSignal<OpencodeConfigFile | null>(null);
  const [configError, setConfigError] = createSignal<string | null>(null);
  const [revealBusy, setRevealBusy] = createSignal(false);

  const selectedEntry = createMemo(() =>
    props.mcpServers.find((entry) => entry.name === props.selectedMcp) ?? null,
  );

  const quickConnectList = createMemo(() => props.quickConnect);

  let configRequestId = 0;
  createEffect(() => {
    const root = props.activeWorkspaceRoot.trim();
    const nextId = (configRequestId += 1);

    if (!isTauriRuntime()) {
      setProjectConfig(null);
      setGlobalConfig(null);
      setConfigError(null);
      return;
    }

    void (async () => {
      try {
        setConfigError(null);

        const [project, global] = await Promise.all([
          root ? readOpencodeConfig("project", root) : Promise.resolve(null),
          readOpencodeConfig("global", root),
        ]);

        if (nextId !== configRequestId) return;
        setProjectConfig(project);
        setGlobalConfig(global);
      } catch (e) {
        if (nextId !== configRequestId) return;
        setProjectConfig(null);
        setGlobalConfig(null);
        setConfigError(e instanceof Error ? e.message : translate("mcp.config_load_failed"));
      }
    })();
  });

  const activeConfig = createMemo(() =>
    configScope() === "project" ? projectConfig() : globalConfig(),
  );

  const revealLabel = () => (isWindowsPlatform() ? translate("mcp.open_file") : translate("mcp.reveal_in_finder"));

  const canRevealConfig = () => {
    if (!isTauriRuntime() || revealBusy()) return false;
    if (configScope() === "project" && !props.activeWorkspaceRoot.trim()) return false;
    return Boolean(activeConfig()?.exists);
  };

  const revealConfig = async () => {
    if (!isTauriRuntime()) return;
    if (revealBusy()) return;
    const root = props.activeWorkspaceRoot.trim();

    if (configScope() === "project" && !root) {
      setConfigError(translate("mcp.pick_workspace_error"));
      return;
    }

    setRevealBusy(true);
    setConfigError(null);
    try {
      const resolved = await readOpencodeConfig(configScope(), root);

      const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
      if (isWindowsPlatform()) {
        await openPath(resolved.path);
      } else {
        await revealItemInDir(resolved.path);
      }
    } catch (e) {
      setConfigError(e instanceof Error ? e.message : translate("mcp.reveal_config_failed"));
    } finally {
      setRevealBusy(false);
    }
  };

  // Convert name to slug (same logic used when adding MCPs)
  const toSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");

  // Look up status by slug, not display name
  const quickConnectStatus = (name: string) => {
    const slug = toSlug(name);
    return props.mcpStatuses[slug];
  };

  const isQuickConnectConnected = (name: string) => {
    const status = quickConnectStatus(name);
    return status?.status === "connected";
  };

  const canConnect = (entry: McpDirectoryInfo) =>
    props.mode === "host" && isTauriRuntime() && !props.busy && !!props.activeWorkspaceRoot.trim();

  return (
    <section class="space-y-6">
        <div class="space-y-1">
          <h2 class="text-lg font-semibold text-gray-12">{translate("mcp.title")}</h2>
          <p class="text-sm text-gray-11">
            {translate("mcp.description")}
          </p>
        </div>

        <div class="grid gap-6 lg:grid-cols-[1.5fr_1fr] animate-in fade-in slide-in-from-top-11 duration-300">
          <div class="space-y-6">
            <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
              <div class="flex items-start justify-between gap-4">
                <div>
                  <div class="text-sm font-medium text-gray-12">{translate("mcp.mcps_title")}</div>
                  <div class="text-xs text-gray-10">
                    {translate("mcp.connect_mcp_hint")}
                  </div>
                </div>
                <div class="text-xs text-gray-10 text-right">
                  <div>{props.mcpServers.length} {translate("mcp.configured")}</div>
                  <Show when={props.mcpLastUpdatedAt}>
                    <div>{translate("mcp.updated")} {formatRelativeTime(props.mcpLastUpdatedAt ?? Date.now())}</div>
                  </Show>
                </div>
              </div>
              <Show when={props.mcpStatus}>
                <div class="text-xs text-gray-10">{props.mcpStatus}</div>
              </Show>
            </div>

            <Show when={props.showMcpReloadBanner}>
              <div class="bg-gray-2/60 border border-gray-6/70 rounded-2xl px-4 py-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                <div>
                  <div class="text-sm font-medium text-gray-12">{translate("mcp.reload_banner_title")}</div>
                  <div class="text-xs text-gray-10">
                    {props.reloadBlocked
                      ? translate("mcp.reload_banner_description_blocked")
                      : translate("mcp.reload_banner_description")}
                  </div>
                </div>
                <Button
                  variant="secondary"
                  onClick={() => props.reloadMcpEngine()}
                  disabled={props.reloadBlocked}
                  title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
                >
                  {translate("mcp.reload_engine")}
                </Button>
              </div>
            </Show>

            <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
              <div class="flex items-center justify-between">
                <div class="text-sm font-medium text-gray-12">{translate("mcp.quick_connect_title")}</div>
                <div class="text-[11px] text-gray-10">{translate("mcp.oauth_only_label")}</div>
              </div>
              <div class="grid gap-3">
                <For each={quickConnectList()}>
                  {(entry) => (
                    <div class="rounded-2xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-3">
                      <div class="flex items-start justify-between gap-4">
                        <div>
                          <div class="text-sm font-medium text-gray-12">{entry.name}</div>
                          <div class="text-xs text-gray-10 mt-1">{entry.description}</div>
                          <div class="text-xs text-gray-7 font-mono mt-1">
                            {entry.type === "local" ? entry.command?.join(" ") : entry.url}
                          </div>
                        </div>
                        <div class="flex flex-col items-end gap-2">
                          <Show
                            when={!isQuickConnectConnected(entry.name)}
                            fallback={
                              <div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-7/10 border border-green-7/20">
                                <CheckCircle2 size={16} class="text-green-11" />
                                <span class="text-sm text-green-11">{translate("mcp.connected_status")}</span>
                              </div>
                            }
                          >
                            <Button
                              variant="secondary"
                              onClick={() => props.connectMcp(entry)}
                              disabled={!canConnect(entry) || props.mcpConnectingName === entry.name}
                            >
                              {props.mcpConnectingName === entry.name ? (
                                <>
                                  <Loader2 size={16} class="animate-spin" />
                                  {translate("mcp.connecting")}
                                </>
                              ) : (
                                <>
                                  <PlugZap size={16} />
                                  {translate("mcp.connect")}
                                </>
                              )}
                            </Button>
                          </Show>
                          <Show when={quickConnectStatus(entry.name)}>
                            {(status) => (
                              <Show when={status().status !== "connected"}>
                                <div class={`text-[11px] px-2 py-1 rounded-full border ${statusBadge(status().status)}`}>
                                  {statusLabel(status().status, currentLocale())}
                                </div>
                              </Show>
                            )}
                          </Show>
                        </div>
                      </div>
                      <div class="text-[11px] text-gray-10">{translate("mcp.no_env_vars")}</div>
                    </div>
                  )}
                </For>
              </div>
            </div>

            <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
              <div class="flex items-center justify-between">
                <div class="text-sm font-medium text-gray-12">{translate("mcp.connected_title")}</div>
                <div class="text-[11px] text-gray-10">{translate("mcp.from_opencode_json")}</div>
              </div>
              <Show
                when={props.mcpServers.length}
                fallback={
                  <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
                    {translate("mcp.no_servers_yet")}
                  </div>
                }
              >
                <div class="grid gap-3">
                  <For each={props.mcpServers}>
                    {(entry) => {
                      const resolved = props.mcpStatuses[entry.name];
                      const status =
                        entry.config.enabled === false
                          ? "disabled"
                          : resolved?.status
                            ? resolved.status
                            : "disconnected";
                      return (
                        <button
                          type="button"
                          class={`text-left rounded-2xl border px-4 py-3 transition-all ${
                            props.selectedMcp === entry.name
                              ? "border-gray-8 bg-gray-2/70"
                              : "border-gray-6/70 bg-gray-1/40 hover:border-gray-7"
                          }`}
                          onClick={() => props.setSelectedMcp(entry.name)}
                        >
                          <div class="flex items-center justify-between gap-3">
                            <div>
                              <div class="text-sm font-medium text-gray-12">{entry.name}</div>
                              <div class="text-xs text-gray-10 font-mono">
                                {entry.config.type === "remote" ? entry.config.url : entry.config.command?.join(" ")}
                              </div>
                            </div>
                            <div class={`text-[11px] px-2 py-1 rounded-full border ${statusBadge(status)}`}>
                              {statusLabel(status, currentLocale())}
                            </div>
                          </div>
                        </button>
                      );
                    }}
                  </For>
                </div>
              </Show>
            </div>

            <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
              <div class="flex items-start justify-between gap-4">
                <div class="space-y-1">
                  <div class="text-sm font-medium text-gray-12">{translate("mcp.edit_config_title")}</div>
                  <div class="text-xs text-gray-10">
                    {translate("mcp.edit_config_description")}
                  </div>
                </div>
                <a
                  href="https://opencode.ai/docs/mcp-servers/"
                  target="_blank"
                  rel="noopener noreferrer"
                  class="inline-flex items-center gap-1.5 text-xs text-gray-10 hover:text-gray-12 underline decoration-gray-6/30 underline-offset-4 transition-colors"
                >
                  <ExternalLink size={12} />
                  {translate("mcp.docs_link")}
                </a>
              </div>

              <div class="flex items-center gap-2">
                <button
                  class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
                    configScope() === "project"
                      ? "bg-gray-12/10 text-gray-12 border-gray-6/30"
                      : "text-gray-10 border-gray-6 hover:text-gray-12"
                  }`}
                  onClick={() => setConfigScope("project")}
                >
                  {translate("mcp.scope_project")}
                </button>
                <button
                  class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
                    configScope() === "global"
                      ? "bg-gray-12/10 text-gray-12 border-gray-6/30"
                      : "text-gray-10 border-gray-6 hover:text-gray-12"
                  }`}
                  onClick={() => setConfigScope("global")}
                >
                  {translate("mcp.scope_global")}
                </button>
              </div>

              <div class="flex flex-col gap-1 text-xs text-gray-10">
                <div>{translate("mcp.config_label")}</div>
                <div class="text-gray-7 font-mono truncate">
                  {activeConfig()?.path ?? translate("mcp.config_not_loaded")}
                </div>
              </div>

              <div class="flex items-center justify-between gap-3">
                <Button
                  variant="secondary"
                  onClick={revealConfig}
                  disabled={!canRevealConfig()}
                >
                  <Show
                    when={revealBusy()}
                    fallback={
                      <>
                        <FolderOpen size={16} />
                        {revealLabel()}
                      </>
                    }
                  >
                    <Loader2 size={16} class="animate-spin" />
                    {translate("mcp.opening_label")}
                  </Show>
                </Button>
                <Show when={activeConfig() && activeConfig()!.exists === false}>
                  <div class="text-[11px] text-zinc-600">{translate("mcp.file_not_found")}</div>
                </Show>
              </div>

              <Show when={configError()}>
                <div class="text-xs text-red-300">{configError()}</div>
              </Show>
            </div>
          </div>

          <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4 lg:sticky lg:top-6 self-start">
            <div class="flex items-center justify-between">
              <div class="text-sm font-medium text-gray-12">{translate("mcp.details_title")}</div>
              <div class="text-xs text-gray-10">{selectedEntry()?.name ?? translate("mcp.select_server_hint").split(" ").slice(0, 3).join(" ")}</div>
            </div>

            <Show
              when={selectedEntry()}
              fallback={
                <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
                  {translate("mcp.select_server_hint")}
                </div>
              }
            >
              {(entry) => (
                <div class="space-y-4">
                  <div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
                    <div class="flex items-center gap-2 text-sm text-gray-12">
                      <Settings size={16} />
                      {entry().name}
                    </div>
                    <div class="text-xs text-gray-10 font-mono break-all">
                      {entry().config.type === "remote" ? entry().config.url : entry().config.command?.join(" ")}
                    </div>
                    <div class="flex items-center gap-2">
                      {(() => {
                        const resolved = props.mcpStatuses[entry().name];
                        const status =
                          entry().config.enabled === false
                            ? "disabled"
                            : resolved?.status
                              ? resolved.status
                              : "disconnected";
                        return (
                          <span class={`inline-flex items-center gap-2 text-[11px] px-2 py-1 rounded-full border ${statusBadge(status)}`}>
                            {statusLabel(status, currentLocale())}
                          </span>
                        );
                      })()}
                    </div>
                  </div>

                  <div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
                    <div class="text-xs text-gray-11 uppercase tracking-wider">{translate("mcp.capabilities_label")}</div>
                    <div class="flex flex-wrap gap-2">
                      <span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
                        {translate("mcp.tools_enabled_label")}
                      </span>
                      <span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
                        {translate("mcp.oauth_ready_label")}
                      </span>
                    </div>
                    <div class="text-xs text-gray-10">
                      {translate("mcp.usage_hint_text")}
                    </div>
                  </div>

                  <div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
                    <div class="text-xs text-gray-11 uppercase tracking-wider">{translate("mcp.next_steps_label")}</div>
                    <div class="flex items-center gap-2 text-xs text-gray-10">
                      <CheckCircle2 size={14} />
                      {translate("mcp.reload_step")}
                    </div>
                    <div class="flex items-center gap-2 text-xs text-gray-10">
                      <CircleAlert size={14} />
                      {translate("mcp.auth_step")}
                    </div>
                    {(() => {
                      const status = props.mcpStatuses[entry().name];
                      if (!status || status.status !== "failed") return null;
                      return (
                        <div class="text-xs text-red-11">
                          {"error" in status ? status.error : translate("mcp.connection_failed")}
                        </div>
                      );
                    })()}
                  </div>
                </div>
              )}
            </Show>
          </div>
        </div>
    </section>
  );
}



================================================
FILE: packages/app/src/app/pages/onboarding.tsx
================================================
import { For, Match, Show, Switch, createSignal } from "solid-js";
import type { Mode, OnboardingStep } from "../types";
import type { WorkspaceInfo } from "../lib/tauri";
import { CheckCircle2, ChevronDown, Circle, Globe } from "lucide-solid";

import Button from "../components/button";
import OnboardingWorkspaceSelector from "../components/onboarding-workspace-selector";
import OpenWorkLogo from "../components/openwork-logo";
import TextInput from "../components/text-input";
import { isTauriRuntime, isWindowsPlatform } from "../utils/index";
import { currentLocale, t } from "../../i18n";

export type OnboardingViewProps = {
  mode: Mode | null;
  onboardingStep: OnboardingStep;
  rememberModeChoice: boolean;
  busy: boolean;
  baseUrl: string;
  clientDirectory: string;
  openworkHostUrl: string;
  openworkToken: string;
  newAuthorizedDir: string;
  authorizedDirs: string[];
  activeWorkspacePath: string;
  workspaces: WorkspaceInfo[];
  localHostLabel: string;
  engineRunning: boolean;
  engineBaseUrl: string | null;
  engineDoctorFound: boolean | null;
  engineDoctorSupportsServe: boolean | null;
  engineDoctorVersion: string | null;
  engineDoctorResolvedPath: string | null;
  engineDoctorNotes: string[];
  engineDoctorServeHelpStdout: string | null;
  engineDoctorServeHelpStderr: string | null;
  engineDoctorCheckedAt: number | null;
  engineInstallLogs: string | null;
  error: string | null;
  developerMode: boolean;
  isWindows: boolean;
  onBaseUrlChange: (value: string) => void;
  onClientDirectoryChange: (value: string) => void;
  onOpenworkHostUrlChange: (value: string) => void;
  onOpenworkTokenChange: (value: string) => void;
  onModeSelect: (mode: Mode) => void;
  onRememberModeToggle: () => void;
  onStartHost: () => void;
  onCreateWorkspace: (preset: "starter" | "automation" | "minimal", folder: string | null) => void;
  onPickWorkspaceFolder: () => Promise<string | null>;
  onImportWorkspaceConfig: () => void;
  importingWorkspaceConfig: boolean;
  onAttachHost: () => void;
  onConnectClient: () => void;
  onBackToMode: () => void;
  onSetAuthorizedDir: (value: string) => void;
  onAddAuthorizedDir: () => void;
  onAddAuthorizedDirFromPicker: () => void;
  onRemoveAuthorizedDir: (index: number) => void;
  onRefreshEngineDoctor: () => void;
  onInstallEngine: () => void;
  onShowSearchNotes: () => void;
  onOpenSettings: () => void;
  themeMode: "light" | "dark" | "system";
  setThemeMode: (value: "light" | "dark" | "system") => void;
};

export default function OnboardingView(props: OnboardingViewProps) {
  // Translation helper that uses current language from i18n
  const translate = (key: string) => t(key, currentLocale());
  const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);

  const engineDoctorAvailable = () =>
    props.engineDoctorFound === true && props.engineDoctorSupportsServe === true;

  const engineStatusLabel = () => {
    if (props.engineDoctorFound == null || props.engineDoctorSupportsServe == null) {
      return translate("onboarding.checking_cli");
    }
    if (!props.engineDoctorFound) return translate("onboarding.cli_not_found");
    if (!props.engineDoctorSupportsServe) return translate("onboarding.cli_needs_update");
    if (props.engineDoctorVersion) {
      return translate("onboarding.cli_version").replace("{version}", props.engineDoctorVersion);
    }
    return translate("onboarding.cli_ready");
  };

  const serveHelpOutput = () => {
    const parts = [
      props.engineDoctorServeHelpStdout,
      props.engineDoctorServeHelpStderr,
    ].filter((value): value is string => Boolean(value && value.trim()));
    return parts.join("\n\n");
  };

  return (
    <Switch>
      <Match when={props.onboardingStep === "connecting"}>
        <div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative overflow-hidden">
          <div class="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-50" />
          <div class="z-10 flex flex-col items-center gap-6">
            <div class="relative">
              <OpenWorkLogo size={40} />
            </div>
            <div class="text-center">
              <h2 class="text-xl font-medium mb-2">
                {props.mode === "host" ? translate("onboarding.starting_host") : translate("onboarding.searching_host")}
              </h2>
              <p class="text-gray-10 text-sm">
                {props.mode === "host"
                  ? translate("onboarding.getting_ready")
                  : translate("onboarding.verifying")}
              </p>

            </div>
          </div>
        </div>
      </Match>

      <Match when={props.onboardingStep === "host"}>
        <div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
          <div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />

          <div class="max-w-lg w-full z-10 space-y-6">
              <div class="text-center space-y-2">
                <div class="">
                  <OpenWorkLogo size={48} />
                </div>
              <h2 class="text-2xl font-bold tracking-tight">
                {props.workspaces.length <= 1 ? translate("onboarding.create_first_workspace") : translate("onboarding.create_workspace")}
              </h2>
              <p class="text-gray-11 text-sm leading-relaxed">
                  {translate("onboarding.workspace_folder_label")}
              </p>
            </div>

            <div class="space-y-4">
              <div class="bg-gray-2/40 border border-gray-6 rounded-2xl p-4 flex flex-wrap items-center justify-between gap-3">
                <div>
                  <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">{translate("onboarding.theme_label")}</div>
                  <div class="text-sm text-gray-12">{translate("onboarding.theme_current").replace("{mode}", props.themeMode)}</div>
                </div>
                <div class="flex flex-wrap gap-2">
                  <button
                    class={`text-xs px-3 py-1 rounded-full border transition-colors ${
                      props.themeMode === "system"
                        ? "bg-gray-12/10 text-gray-12 border-gray-6/30"
                        : "text-gray-10 border-gray-6 hover:text-gray-12"
                    }`}
                    onClick={() => props.setThemeMode("system")}
                  >
                    {translate("onboarding.theme_system")}
                  </button>
                  <button
                    class={`text-xs px-3 py-1 rounded-full border transition-colors ${
                      props.themeMode === "light"
                        ? "bg-gray-12/10 text-gray-12 border-gray-6/30"
                        : "text-gray-10 border-gray-6 hover:text-gray-12"
                    }`}
                    onClick={() => props.setThemeMode("light")}
                  >
                    {translate("onboarding.theme_light")}
                  </button>
                  <button
                    class={`text-xs px-3 py-1 rounded-full border transition-colors ${
                      props.themeMode === "dark"
                        ? "bg-gray-12/10 text-gray-12 border-gray-6/30"
                        : "text-gray-10 border-gray-6 hover:text-gray-12"
                    }`}
                    onClick={() => props.setThemeMode("dark")}
                  >
                    {translate("onboarding.theme_dark")}
                  </button>
                </div>
              </div>

              <OnboardingWorkspaceSelector
                defaultPath="~/OpenWork/Workspace"
                onConfirm={props.onCreateWorkspace}
                onPickFolder={props.onPickWorkspaceFolder}
              />

              <div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
                <div class="flex items-center justify-between gap-4">
                  <div class="min-w-0">
                    <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">Import</div>
                    <div class="mt-1 text-sm text-gray-12">Use an existing workspace config.</div>
                    <div class="text-xs text-gray-10">Imports `.opencode` and `opencode.json` only.</div>
                  </div>
                  <Button
                    variant="secondary"
                    class="text-xs h-8 px-3 shrink-0"
                    onClick={props.onImportWorkspaceConfig}
                    disabled={props.importingWorkspaceConfig || props.busy}
                  >
                    Import config
                  </Button>
                </div>
              </div>

              <div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
                <div class="flex items-center justify-between gap-4">
                  <div class="min-w-0">
                    <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">{translate("onboarding.access_label")}</div>
                    <div class="mt-1 text-sm text-gray-12">
                      {translate("onboarding.folders_allowed")
                        .replace("{count}", String(props.authorizedDirs.length))
                        .replace("{plural}", props.authorizedDirs.length === 1 ? "" : "s")}
                    </div>
                    <div class="text-xs text-gray-10">{translate("onboarding.manage_access_hint")}</div>
                  </div>
                  <div class="text-xs text-gray-7 font-mono truncate max-w-[9rem]">
                    <Show when={props.developerMode}>{props.authorizedDirs[0] ?? ""}</Show>
                  </div>
                </div>
              </div>
            </div>
            <Button
              onClick={props.onStartHost}
              disabled={props.busy || !props.activeWorkspacePath.trim()}
              class="w-full py-3 text-base"
            >
              {translate("onboarding.start")}
            </Button>

            <Button variant="ghost" onClick={props.onBackToMode} disabled={props.busy} class="w-full">
              {translate("onboarding.back")}
            </Button>

            <details class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3">
              <summary class="flex items-center justify-between cursor-pointer text-xs text-gray-10">
                {translate("onboarding.advanced_settings")}
                <ChevronDown size={14} class="text-gray-7" />
              </summary>
              <div class="pt-3 space-y-3">
                <div class="text-xs text-gray-10">{translate("onboarding.manage_access_hint")}</div>

                <div class="flex items-center justify-between gap-3 rounded-xl border border-gray-6 bg-gray-1/40 px-3 py-2">
                  <div class="text-xs text-gray-10">{translate("onboarding.open_settings_hint")}</div>
                  <Button
                    variant="outline"
                    class="text-xs h-8 py-0 px-3 shrink-0"
                    onClick={props.onOpenSettings}
                  >
                    {translate("onboarding.open_settings")}
                  </Button>
                </div>

                <div class="space-y-3">
                  <div class="flex gap-2">
                    <input
                      class="w-full bg-gray-2/50 border border-gray-6 rounded-xl px-3 py-2 text-sm text-gray-12 placeholder-gray-7 focus:outline-none focus:ring-1 focus:ring-gray-8 focus:border-gray-8 transition-all"
                      placeholder={translate("onboarding.add_folder_path")}
                      value={props.newAuthorizedDir}
                      onInput={(e) => props.onSetAuthorizedDir(e.currentTarget.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") {
                          props.onAddAuthorizedDir();
                        }
                      }}
                    />
                    <Show when={isTauriRuntime()}>
                      <Button
                        variant="outline"
                        onClick={props.onAddAuthorizedDirFromPicker}
                        disabled={props.busy}
                      >
                        {translate("onboarding.pick")}
                      </Button>
                    </Show>
                    <Button
                      variant="secondary"
                      onClick={props.onAddAuthorizedDir}
                      disabled={!props.newAuthorizedDir.trim()}
                    >
                      {translate("onboarding.add")}
                    </Button>
                  </div>
                  <div class="text-xs text-gray-10">{engineStatusLabel()}</div>

                  <Show when={props.authorizedDirs.length}>
                    <div class="space-y-2">
                      <For each={props.authorizedDirs}>
                        {(dir, idx) => (
                          <div class="flex items-center justify-between gap-3 rounded-xl bg-gray-1/20 border border-gray-6 px-3 py-2">
                            <div class="min-w-0 text-xs font-mono text-gray-11 truncate">{dir}</div>
                            <Button
                              variant="ghost"
                              class="!p-2 rounded-lg text-xs text-gray-11 hover:text-gray-12"
                              onClick={() => props.onRemoveAuthorizedDir(idx())}
                              disabled={props.busy}
                              title={translate("onboarding.remove")}
                            >
                              {translate("onboarding.remove")}
                            </Button>
                          </div>
                        )}
                      </For>
                    </div>
                  </Show>
                </div>

                <Show when={isTauriRuntime() && props.developerMode}>
                  <div class="rounded-2xl bg-gray-2/40 border border-gray-6 p-4">
                    <div class="flex items-start justify-between gap-4">
                      <div class="min-w-0">
                        <div class="text-sm font-medium text-gray-12">{translate("onboarding.cli_label")}</div>
                        <div class="mt-1 text-xs text-gray-10">
                          <Show when={props.engineDoctorFound != null} fallback={<span>{translate("onboarding.cli_checking")}</span>}>
                            <Show when={props.engineDoctorFound} fallback={<span>{translate("onboarding.cli_not_found_hint")}</span>}>
                              <span class="font-mono">{props.engineDoctorVersion ?? translate("onboarding.cli_version_installed")}</span>
                              <Show when={props.engineDoctorResolvedPath}>
                                <span class="text-gray-7"> · </span>
                                <span class="font-mono text-gray-7 truncate">{props.engineDoctorResolvedPath}</span>
                              </Show>
                            </Show>
                          </Show>
                        </div>
                      </div>

                      <Button variant="secondary" onClick={props.onRefreshEngineDoctor} disabled={props.busy}>
                        {translate("onboarding.cli_recheck")}
                      </Button>
                    </div>

                    <Show when={props.engineDoctorFound === false}>
                      <div class="mt-4 space-y-2">
                        <div class="text-xs text-gray-10">
                          {isWindowsPlatform()
                            ? translate("onboarding.cli_install_commands")
                            : translate("onboarding.install_instruction")}
                        </div>
                        <Show when={isWindowsPlatform()}>
                          <div class="text-xs text-gray-10 space-y-1 font-mono">
                            <div>choco install opencode</div>
                            <div>scoop install extras/opencode</div>
                            <div>npm install -g opencode-ai</div>
                          </div>
                        </Show>
                        <div class="flex gap-2 pt-2">
                          <Button onClick={props.onInstallEngine} disabled={props.busy}>
                            {translate("onboarding.install")}
                          </Button>
                          <Button variant="outline" onClick={props.onShowSearchNotes} disabled={props.busy}>
                            {translate("onboarding.show_search_notes")}
                          </Button>
                        </div>
                      </div>
                    </Show>

                    <Show when={props.engineInstallLogs}>
                      <pre class="mt-4 max-h-48 overflow-auto rounded-xl bg-gray-1/50 border border-gray-6 p-3 text-xs text-gray-11 whitespace-pre-wrap">
                        {props.engineInstallLogs}
                      </pre>
                    </Show>

                    <Show when={props.engineDoctorCheckedAt != null}>
                      <div class="mt-3 text-[11px] text-gray-7">
                        {translate("onboarding.last_checked").replace("{time}", new Date(props.engineDoctorCheckedAt ?? 0).toLocaleTimeString())}
                      </div>
                    </Show>
                  </div>
                </Show>

                <Show when={!engineDoctorAvailable()}>
                  <div class="text-xs text-gray-10">
                    {props.isWindows
                      ? translate("onboarding.windows_install_instruction")
                      : translate("onboarding.install_instruction")}
                  </div>
                </Show>

                <Show when={engineDoctorAvailable()}>
                  <div class="text-xs text-gray-7">{translate("onboarding.ready_message")}</div>
                </Show>

                <Show
                  when={
                    props.engineDoctorResolvedPath ||
                    props.engineDoctorVersion ||
                    props.engineDoctorNotes.length ||
                    serveHelpOutput()
                  }
                >
                  <div class="rounded-xl bg-gray-1/40 border border-gray-6 p-3 space-y-3 text-xs text-gray-10">
                    <Show when={props.engineDoctorResolvedPath}>
                      <div>
                        <div class="text-[11px] text-gray-8">{translate("onboarding.resolved_path")}</div>
                        <div class="font-mono break-all">{props.engineDoctorResolvedPath}</div>
                      </div>
                    </Show>
                    <Show when={props.engineDoctorVersion}>
                      <div>
                        <div class="text-[11px] text-gray-8">{translate("onboarding.version")}</div>
                        <div class="font-mono">{props.engineDoctorVersion}</div>
                      </div>
                    </Show>
                    <Show when={props.engineDoctorNotes.length}>
                      <div>
                        <div class="text-[11px] text-gray-8">{translate("onboarding.search_notes")}</div>
                        <pre class="whitespace-pre-wrap break-words text-xs text-gray-10">
                          {props.engineDoctorNotes.join("\n")}
                        </pre>
                      </div>
                    </Show>
                    <Show when={serveHelpOutput()}>
                      <div>
                        <div class="text-[11px] text-gray-8">{translate("onboarding.serve_help")}</div>
                        <pre class="whitespace-pre-wrap break-words text-xs text-gray-10">{serveHelpOutput()}</pre>
                      </div>
                    </Show>
                  </div>
                </Show>
              </div>
            </details>

            <Show when={props.error}>
              <div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
                {props.error}
              </div>
            </Show>
          </div>
        </div>
      </Match>

      <Match when={props.onboardingStep === "client"}>
        <div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
          <div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />

          <div class="max-w-md w-full z-10 space-y-8">
              <div class="text-center space-y-2">
                <div class="w-12 h-12 bg-gray-2 rounded-2xl mx-auto flex items-center justify-center border border-gray-6 mb-6">
                  <Globe size={20} class="text-gray-11" />
                </div>
                <h2 class="text-2xl font-bold tracking-tight">{translate("onboarding.remote_workspace_title")}</h2>
              <p class="text-gray-11 text-sm leading-relaxed">
                  {translate("onboarding.remote_workspace_description")}
              </p>
            </div>

            <div class="space-y-4">
              <div class="rounded-2xl border border-gray-6 bg-gray-1/50 px-4 py-3">
                <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">
                  {translate("onboarding.remote_workspace_title")}
                </div>
                <div class="mt-1 text-sm text-gray-12">{translate("onboarding.remote_workspace_description")}</div>
              </div>

              <div class="space-y-4">
                <TextInput
                  label={translate("dashboard.remote_base_url_label")}
                  placeholder={translate("dashboard.remote_base_url_placeholder")}
                  value={props.baseUrl}
                  onInput={(e) => props.onBaseUrlChange(e.currentTarget.value)}
                />
                <TextInput
                  label={translate("dashboard.remote_directory_label")}
                  placeholder={translate("dashboard.remote_directory_placeholder")}
                  value={props.clientDirectory}
                  onInput={(e) => props.onClientDirectoryChange(e.currentTarget.value)}
                  hint={translate("dashboard.remote_directory_hint")}
                />
              </div>

              <details class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3">
                <summary class="flex items-center justify-between cursor-pointer text-xs text-gray-10">
                  {translate("onboarding.advanced_openwork_host")}
                  <ChevronDown size={14} class="text-gray-7" />
                </summary>
                <div class="pt-3 space-y-3">
                  <div class="text-xs text-gray-10">{translate("onboarding.advanced_openwork_hint")}</div>
                  <TextInput
                    label={translate("dashboard.openwork_host_label")}
                    placeholder={translate("dashboard.openwork_host_placeholder")}
                    value={props.openworkHostUrl}
                    onInput={(e) => props.onOpenworkHostUrlChange(e.currentTarget.value)}
                    hint={translate("dashboard.openwork_host_hint")}
                  />

                  <label class="block">
                    <div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
                    <div class="flex items-center gap-2">
                      <input
                        type={openworkTokenVisible() ? "text" : "password"}
                        value={props.openworkToken}
                        onInput={(e) => props.onOpenworkTokenChange(e.currentTarget.value)}
                        placeholder={translate("dashboard.openwork_host_token_placeholder")}
                        disabled={props.busy}
                        class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
                      />
                      <Button
                        variant="outline"
                        class="text-xs h-9 px-3 shrink-0"
                        onClick={() => setOpenworkTokenVisible((prev) => !prev)}
                        disabled={props.busy}
                      >
                        {openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
                      </Button>
                    </div>
                    <div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
                  </label>
                </div>
              </details>

              <Button
                onClick={props.onConnectClient}
                disabled={props.busy || (!props.baseUrl.trim() && !props.openworkHostUrl.trim())}
                class="w-full py-3 text-base"
              >
                {translate("onboarding.remote_workspace_action")}
              </Button>

              <Button variant="ghost" onClick={props.onBackToMode} disabled={props.busy} class="w-full">
                {translate("onboarding.back")}
              </Button>

              <Show when={props.error}>
                <div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
                  {props.error}
                </div>
              </Show>
            </div>
          </div>
        </div>
      </Match>

      <Match when={true}>
        <div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
          <div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />

          <div class="max-w-xl w-full z-10 space-y-12">
            <div class="text-center space-y-4">
              <div class="flex items-center justify-center gap-3 mb-6">
                <div class="">
                  <OpenWorkLogo size={48} />
                </div>
                <h1 class="text-3xl font-bold tracking-tight text-gray-12">OpenWork</h1>
              </div>
              <h2 class="text-xl text-gray-11">{translate("onboarding.welcome_title")}</h2>
            </div>

            <div class="space-y-4">
              <button
                onClick={() => props.onModeSelect("host")}
                class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-indigo-6/10 hover:-translate-y-0.5 flex items-start gap-6"
              >
                <div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-7/20 to-purple-7/20 flex items-center justify-center border border-indigo-7/20 group-hover:border-indigo-7/40 transition-colors">
                  <Circle size={18} class="text-indigo-11" />
                </div>
                <div>
                  <h3 class="text-xl font-medium text-gray-12 mb-2">{translate("onboarding.run_local")}</h3>
                  <p class="text-gray-10 text-sm leading-relaxed mb-4">
                      {translate("onboarding.run_local_description")}
                  </p>
                  <Show when={props.developerMode}>
                    <div class="flex items-center gap-2 text-xs font-mono text-indigo-11/80 bg-indigo-2/10 w-fit px-2 py-1 rounded border border-indigo-7/10">
                      <div class="w-1.5 h-1.5 rounded-full bg-indigo-8 animate-pulse" />
                      {props.localHostLabel}
                    </div>
                  </Show>
                </div>
              </button>

              <Show when={props.engineRunning && props.engineBaseUrl}>
                <div class="rounded-2xl bg-gray-2/40 border border-gray-6 p-5 flex items-center justify-between">
                  <div>
                    <div class="text-sm text-gray-12 font-medium">{translate("onboarding.engine_running")}</div>
                    <div class="text-xs text-gray-10">{translate("onboarding.attach_description")}</div>
                    <Show when={props.developerMode}>
                      <div class="text-xs text-gray-10 font-mono truncate max-w-[14rem] md:max-w-[22rem]">
                        {props.engineBaseUrl}
                      </div>
                    </Show>
                  </div>
                  <Button variant="secondary" onClick={props.onAttachHost} disabled={props.busy}>
                    {translate("onboarding.attach")}
                  </Button>
                </div>
              </Show>

              <button
                onClick={() => props.onModeSelect("client")}
                class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-gray-12/10 hover:-translate-y-0.5 flex items-start gap-6"
              >
                <div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6 group-hover:border-gray-7 transition-colors">
                  <Globe size={18} class="text-gray-11" />
                </div>
                <div>
                  <h3 class="text-xl font-medium text-gray-12 mb-2">
                    {translate("onboarding.remote_workspace_card_title")}
                  </h3>
                  <p class="text-gray-10 text-sm leading-relaxed mb-4">
                    {translate("onboarding.remote_workspace_card_description")}
                  </p>
                </div>
              </button>

              <div class="flex items-center gap-2 px-2 py-1">
                <button
                  onClick={props.onRememberModeToggle}
                  class="flex items-center gap-2 text-xs text-gray-10 hover:text-gray-11 transition-colors group"
                >
                  <div
                    class={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
                      props.rememberModeChoice
                        ? "bg-indigo-7 border-indigo-7 text-gray-12"
                        : "border-gray-7 bg-transparent group-hover:border-gray-7"
                    }`}
                  >
                    <Show when={props.rememberModeChoice}>
                      <CheckCircle2 size={10} />
                    </Show>
                  </div>
                  {translate("onboarding.remember_choice")}
                </button>
              </div>

              <Show when={props.error}>
                <div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
                  {props.error}
                </div>
              </Show>

              <Show when={props.developerMode}>
                <div class="text-center text-xs text-gray-8">{props.localHostLabel}</div>
              </Show>
            </div>
          </div>
        </div>
      </Match>
    </Switch>
  );
}



================================================
FILE: packages/app/src/app/pages/plugins.tsx
================================================
import { For, Show } from "solid-js";

import type { PluginScope } from "../types";

import Button from "../components/button";
import TextInput from "../components/text-input";
import { Cpu } from "lucide-solid";

export type PluginsViewProps = {
  busy: boolean;
  activeWorkspaceRoot: string;
  canEditPlugins: boolean;
  canUseGlobalScope: boolean;
  accessHint?: string | null;
  pluginScope: PluginScope;
  setPluginScope: (scope: PluginScope) => void;
  pluginConfigPath: string | null;
  pluginList: string[];
  pluginInput: string;
  setPluginInput: (value: string) => void;
  pluginStatus: string | null;
  activePluginGuide: string | null;
  setActivePluginGuide: (value: string | null) => void;
  isPluginInstalled: (name: string, aliases?: string[]) => boolean;
  suggestedPlugins: Array<{
    name: string;
    packageName: string;
    description: string;
    tags: string[];
    aliases?: string[];
    installMode?: "simple" | "guided";
    steps?: Array<{
      title: string;
      description: string;
      command?: string;
      url?: string;
      path?: string;
      note?: string;
    }>;
  }>;
  refreshPlugins: (scopeOverride?: PluginScope) => void;
  addPlugin: (pluginNameOverride?: string) => void;
};

export default function PluginsView(props: PluginsViewProps) {
  return (
    <section class="space-y-6">
      <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
        <div class="flex items-start justify-between gap-4">
          <div class="space-y-1">
            <div class="text-sm font-medium text-gray-12">OpenCode plugins</div>
            <div class="text-xs text-gray-10">Manage `opencode.json` for your project or global OpenCode plugins.</div>
          </div>
          <div class="flex items-center gap-2">
            <button
              class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
                props.pluginScope === "project"
                  ? "bg-gray-12/10 text-gray-12 border-gray-6/20"
                  : "text-gray-10 border-gray-6 hover:text-gray-12"
              }`}
              onClick={() => {
                props.setPluginScope("project");
                props.refreshPlugins("project");
              }}
            >
              Project
            </button>
            <button
              disabled={!props.canUseGlobalScope}
              class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
                props.pluginScope === "global"
                  ? "bg-gray-12/10 text-gray-12 border-gray-6/20"
                  : "text-gray-10 border-gray-6 hover:text-gray-12"
              } ${!props.canUseGlobalScope ? "opacity-40 cursor-not-allowed hover:text-gray-10" : ""}`}
              onClick={() => {
                if (!props.canUseGlobalScope) return;
                props.setPluginScope("global");
                props.refreshPlugins("global");
              }}
            >
              Global
            </button>
            <Button variant="ghost" onClick={() => props.refreshPlugins()}>
              Refresh
            </Button>
          </div>
        </div>

        <div class="flex flex-col gap-1 text-xs text-gray-10">
          <div>Config</div>
          <div class="text-gray-7 font-mono truncate">{props.pluginConfigPath ?? "Not loaded yet"}</div>
          <Show when={props.accessHint}>
            <div class="text-gray-9">{props.accessHint}</div>
          </Show>
        </div>

        <div class="space-y-3">
          <div class="text-xs font-medium text-gray-11 uppercase tracking-wider">Suggested plugins</div>
          <div class="grid gap-3">
            <For each={props.suggestedPlugins}>
              {(plugin) => {
                const isGuided = () => plugin.installMode === "guided";
                const isInstalled = () => props.isPluginInstalled(plugin.packageName, plugin.aliases ?? []);
                const isGuideOpen = () => props.activePluginGuide === plugin.packageName;

                return (
                  <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
                    <div class="flex items-start justify-between gap-4">
                      <div>
                        <div class="text-sm font-medium text-gray-12 font-mono">{plugin.name}</div>
                        <div class="text-xs text-gray-10 mt-1">{plugin.description}</div>
                        <Show when={plugin.packageName !== plugin.name}>
                          <div class="text-xs text-gray-7 font-mono mt-1">{plugin.packageName}</div>
                        </Show>
                      </div>
                      <div class="flex items-center gap-2">
                        <Show when={isGuided()}>
                          <Button
                            variant="ghost"
                            onClick={() => props.setActivePluginGuide(isGuideOpen() ? null : plugin.packageName)}
                          >
                            {isGuideOpen() ? "Hide setup" : "Setup"}
                          </Button>
                        </Show>
                        <Button
                          variant={isInstalled() ? "outline" : "secondary"}
                          onClick={() => props.addPlugin(plugin.packageName)}
                          disabled={
                            props.busy ||
                            isInstalled() ||
                            !props.canEditPlugins ||
                            (props.pluginScope === "project" && !props.activeWorkspaceRoot.trim())
                          }
                        >
                          {isInstalled() ? "Added" : "Add"}
                        </Button>
                      </div>
                    </div>
                    <div class="flex flex-wrap gap-2">
                      <For each={plugin.tags}>
                        {(tag) => (
                          <span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
                            {tag}
                          </span>
                        )}
                      </For>
                    </div>
                    <Show when={isGuided() && isGuideOpen()}>
                      <div class="rounded-xl border border-gray-6/70 bg-gray-1/60 p-4 space-y-3">
                        <For each={plugin.steps ?? []}>
                          {(step, idx) => (
                            <div class="space-y-1">
                              <div class="text-xs font-medium text-gray-11">
                                {idx() + 1}. {step.title}
                              </div>
                              <div class="text-xs text-gray-10">{step.description}</div>
                              <Show when={step.command}>
                                <div class="text-xs font-mono text-gray-12 bg-gray-2/60 border border-gray-6/70 rounded-lg px-3 py-2">
                                  {step.command}
                                </div>
                              </Show>
                              <Show when={step.note}>
                                <div class="text-xs text-gray-10">{step.note}</div>
                              </Show>
                              <Show when={step.url}>
                                <div class="text-xs text-gray-10">
                                  Open: <span class="font-mono text-gray-11">{step.url}</span>
                                </div>
                              </Show>
                              <Show when={step.path}>
                                <div class="text-xs text-gray-10">
                                  Path: <span class="font-mono text-gray-11">{step.path}</span>
                                </div>
                              </Show>
                            </div>
                          )}
                        </For>
                      </div>
                    </Show>
                  </div>
                );
              }}
            </For>
          </div>
        </div>

        <Show
          when={props.pluginList.length}
          fallback={
            <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
              No plugins configured yet.
            </div>
          }
        >
          <div class="grid gap-2">
            <For each={props.pluginList}>
              {(pluginName) => (
                <div class="flex items-center justify-between rounded-xl border border-gray-6/60 bg-gray-1/40 px-4 py-2.5">
                  <div class="text-sm text-gray-12 font-mono">{pluginName}</div>
                  <div class="text-[10px] uppercase tracking-wide text-gray-10">Enabled</div>
                </div>
              )}
            </For>
          </div>
        </Show>

        <div class="flex flex-col gap-3">
          <div class="flex flex-col md:flex-row gap-3">
            <div class="flex-1">
              <TextInput
                label="Add plugin"
                placeholder="opencode-wakatime"
                value={props.pluginInput}
                onInput={(e) => props.setPluginInput(e.currentTarget.value)}
                hint="Add npm package names, e.g. opencode-wakatime"
              />
            </div>
            <Button
              variant="secondary"
              onClick={() => props.addPlugin()}
              disabled={props.busy || !props.pluginInput.trim() || !props.canEditPlugins}
              class="md:mt-6"
            >
              Add
            </Button>
          </div>
          <Show when={props.pluginStatus}>
            <div class="text-xs text-gray-10">{props.pluginStatus}</div>
          </Show>
        </div>
      </div>
    </section>
  );
}



================================================
FILE: packages/app/src/app/pages/proto-workspaces.tsx
================================================
import { For, Show, createMemo, createSignal } from "solid-js";
import {
  ArrowLeft,
  ChevronDown,
  Command,
  Folder,
  Play,
  Plus,
  Server,
  Settings,
  Terminal,
} from "lucide-solid";

import type { WorkspaceInfo } from "../lib/tauri";
import Button from "../components/button";
import OpenWorkLogo from "../components/openwork-logo";
import WorkspaceChip from "../components/workspace-chip";

type ProtoView = "onboarding" | "dashboard" | "session";

type SessionProto = {
  id: string;
  title: string;
  slug: string;
  workspaceId: string;
  updated: string;
  status: "idle" | "running" | "failed";
};

const workspaces: WorkspaceInfo[] = [
  {
    id: "ws-01",
    name: "Finance Ops",
    path: "/Users/susan/FinanceOps",
    preset: "starter",
    workspaceType: "local",
  },
  {
    id: "ws-02",
    name: "Mobile QA Lab",
    path: "/Users/susan/MobileQA",
    preset: "automation",
    workspaceType: "local",
  },
  {
    id: "ws-03",
    name: "Shared Host",
    path: "/Users/bob/Shared",
    preset: "starter",
    workspaceType: "remote",
    baseUrl: "http://10.0.0.8:4096",
  },
];

const sessions: SessionProto[] = [
  {
    id: "s-01",
    title: "Reconcile vendor overages",
    slug: "rv",
    workspaceId: "ws-01",
    updated: "2m ago",
    status: "running",
  },
  {
    id: "s-02",
    title: "Generate QA report",
    slug: "qr",
    workspaceId: "ws-02",
    updated: "28m ago",
    status: "idle",
  },
  {
    id: "s-03",
    title: "Sync policy checklist",
    slug: "pc",
    workspaceId: "ws-01",
    updated: "2h ago",
    status: "failed",
  },
];

const statusStyles: Record<SessionProto["status"], string> = {
  idle: "text-gray-10",
  running: "text-emerald-11",
  failed: "text-red-11",
};

const viewLabels: Record<ProtoView, string> = {
  onboarding: "Onboarding",
  dashboard: "Dashboard",
  session: "Session",
};

const navItems: Array<{
  id: string;
  label: string;
  icon: any;
}> = [
  { id: "home", label: "Dashboard", icon: Command },
  { id: "sessions", label: "Sessions", icon: Play },
  { id: "commands", label: "Commands", icon: Terminal },
  { id: "skills", label: "Skills", icon: Folder },
  { id: "settings", label: "Settings", icon: Settings },
  { id: "mcp", label: "MCPs", icon: Server },
];

export default function ProtoWorkspacesView() {
  const [view, setView] = createSignal<ProtoView>("dashboard");
  const activeWorkspace = createMemo(() => workspaces[0]);
  const activeWorkspaceName = createMemo(() => activeWorkspace().name);

  const workspaceById = (id: string) => workspaces.find((ws) => ws.id === id) ?? workspaces[0];

  return (
    <main class="min-h-screen bg-gray-1 text-gray-12">
      <div class="mx-auto max-w-6xl px-6 py-10 space-y-8">
        <header class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
          <div class="space-y-3">
            <div class="inline-flex items-center gap-2 rounded-full border border-gray-6/70 bg-gray-2/40 px-3 py-1 text-xs text-gray-10">
              Prototype: multi-workspace incremental
            </div>
            <div class="space-y-2">
              <h1 class="text-3xl font-semibold text-gray-12">Multi-workspace flow preview.</h1>
              <p class="text-sm text-gray-10 max-w-2xl">
                UI-only mock that maps to the current onboarding, dashboard, and session layouts.
              </p>
            </div>
          </div>
          <div class="flex flex-wrap items-center gap-2">
            <div class="flex items-center gap-1 rounded-full border border-gray-6/70 bg-gray-2/50 p-1">
              <For each={("onboarding dashboard session".split(" ") as ProtoView[])}>
                {(key) => (
                  <button
                    onClick={() => setView(key)}
                    class={`rounded-full px-3 py-1 text-xs transition-colors ${
                      view() === key
                        ? "bg-gray-12 text-gray-1"
                        : "text-gray-10 hover:text-gray-12"
                    }`}
                  >
                    {viewLabels[key]}
                  </button>
                )}
              </For>
            </div>
          </div>
        </header>

        <Show when={view() === "onboarding"}>
          <div class="min-h-[70vh] flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
            <div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
            <div class="max-w-xl w-full z-10 space-y-10">
              <div class="text-center space-y-4">
                <div class="flex items-center justify-center gap-3 mb-6">
                  <OpenWorkLogo size={48} />
                  <h2 class="text-3xl font-bold tracking-tight text-gray-12">OpenWork</h2>
                </div>
                <h3 class="text-xl text-gray-11">Choose how to connect</h3>
              </div>

              <div class="space-y-4">
                <button class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 rounded-3xl text-left transition-all duration-300 flex items-start gap-6">
                  <div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6">
                    <Play size={18} class="text-gray-11" />
                  </div>
                  <div>
                    <h4 class="text-xl font-medium text-gray-12 mb-2">Run on this machine</h4>
                    <p class="text-gray-10 text-sm leading-relaxed">
                      Start OpenCode locally and pick a workspace folder.
                    </p>
                  </div>
                </button>

                <button class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 rounded-3xl text-left transition-all duration-300 flex items-start gap-6">
                  <div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6">
                    <Server size={18} class="text-gray-11" />
                  </div>
                  <div>
                    <h4 class="text-xl font-medium text-gray-12 mb-2">Connect to a host</h4>
                    <p class="text-gray-10 text-sm leading-relaxed">
                      Pair with an existing host and select a shared workspace.
                    </p>
                  </div>
                </button>
              </div>

              <div class="rounded-2xl border border-gray-6 bg-gray-1/60 px-5 py-4">
                <div class="flex items-center justify-between gap-4">
                  <div class="min-w-0">
                    <div class="text-xs font-semibold text-gray-10 uppercase tracking-wider">Workspaces</div>
                    <div class="mt-1 text-sm text-gray-12">Add a workspace folder or import one.</div>
                  </div>
                  <div class="flex gap-2">
                    <Button variant="secondary" class="text-xs px-3 py-1.5">Pick folder</Button>
                    <Button variant="outline" class="text-xs px-3 py-1.5">Import config</Button>
                  </div>
                </div>
                <div class="mt-4 space-y-2">
                  <For each={workspaces.slice(0, 2)}>
                    {(workspace) => (
                      <div class="flex items-center justify-between gap-3 rounded-xl bg-gray-1/40 border border-gray-6 px-3 py-2">
                        <div class="min-w-0">
                          <div class="text-xs font-medium text-gray-12 truncate">{workspace.name}</div>
                          <div class="text-[11px] text-gray-10 font-mono truncate">{workspace.path}</div>
                        </div>
                        <Button variant="ghost" class="text-xs px-2 py-1">Use</Button>
                      </div>
                    )}
                  </For>
                </div>
              </div>
            </div>
          </div>
        </Show>

        <Show when={view() === "dashboard"}>
          <div class="flex h-[70vh] bg-gray-1 text-gray-12 overflow-hidden rounded-3xl border border-gray-6">
            <aside class="w-60 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
              <div>
                <div class="flex items-center gap-3 mb-10 px-2">
                  <OpenWorkLogo size={28} />
                  <span class="font-bold text-lg tracking-tight">OpenWork</span>
                </div>
                <nav class="space-y-1">
                  <For each={navItems}>
                    {(item) => (
                      <button
                        class={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
                          item.id === "home"
                            ? "bg-gray-2 text-gray-12"
                            : "text-gray-10 hover:text-gray-12 hover:bg-gray-2/50"
                        }`}
                      >
                        <item.icon size={18} />
                        {item.label}
                      </button>
                    )}
                  </For>
                </nav>
              </div>

              <div class="space-y-3">
                <Button variant="secondary" class="w-full">Connect</Button>
                <Button variant="outline" class="w-full">Settings</Button>
              </div>
            </aside>

            <main class="flex-1 overflow-y-auto relative">
              <header class="h-16 flex items-center justify-between px-6 md:px-8 border-b border-gray-6 sticky top-0 bg-gray-1/80 backdrop-blur-md z-10">
                <div class="flex items-center gap-3">
                  <WorkspaceChip
                    workspace={activeWorkspace()}
                    onClick={() => undefined}
                    connecting={false}
                  />
                  <h2 class="text-lg font-medium">Dashboard</h2>
                  <span class="text-xs text-gray-10">Active: {activeWorkspaceName()}</span>
                </div>
                <div class="flex items-center gap-2">
                  <Button variant="outline" class="text-xs">
                    Share config
                  </Button>
                  <Button disabled={false}>
                    <Play size={16} />
                    New Task
                  </Button>
                </div>
              </header>

              <div class="p-6 md:p-8 max-w-4xl mx-auto space-y-8">
                <section>
                  <div class="bg-gradient-to-r from-gray-2 to-gray-4 rounded-3xl p-1">
                    <div class="bg-gray-1 rounded-[22px] p-6 md:p-8 flex flex-col md:flex-row items-center justify-between gap-6">
                      <div class="space-y-2 text-center md:text-left">
                        <h3 class="text-2xl font-semibold text-gray-12">What should we do today?</h3>
                        <p class="text-gray-11">Describe an outcome. OpenWork will run it and keep an audit trail.</p>
                      </div>
                      <div class="w-full md:w-[320px]">
                        <div class="flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/50 px-4 py-3">
                          <input
                            placeholder="Draft a task to run..."
                            class="flex-1 bg-transparent border-none p-0 text-sm text-gray-12 placeholder-gray-7 focus:ring-0"
                          />
                          <button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Run</button>
                        </div>
                      </div>
                    </div>
                  </div>
                </section>

                <section class="space-y-4">
                  <div class="flex items-center justify-between">
                    <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">Workspaces</h3>
                    <div class="flex items-center gap-2">
                      <Button variant="outline" class="text-xs h-8 px-3">
                        Share config
                      </Button>
                      <Button variant="secondary" class="text-xs h-8 px-3">
                        <Plus size={14} />
                        Add workspace
                      </Button>
                    </div>
                  </div>
                  <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                    <For each={workspaces}>
                      {(workspace) => (
                        <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
                          <div class="flex items-start justify-between">
                            <div class="space-y-1">
                              <div class="text-sm font-semibold text-gray-12">{workspace.name}</div>
                              <div class="text-xs text-gray-10 font-mono truncate">{workspace.path}</div>
                            </div>
                            <span class="text-[11px] text-gray-9">
                              {workspace.workspaceType === "remote" ? "Remote" : "Local"}
                            </span>
                          </div>
                          <div class="flex items-center justify-between text-xs text-gray-9">
                            <span>Last active: 2h ago</span>
                            <Button variant="ghost" class="text-xs px-2 py-1">
                              Switch
                            </Button>
                          </div>
                        </div>
                      )}
                    </For>
                  </div>
                </section>

                <section class="space-y-4">
                  <h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">Recent Sessions</h3>
                  <div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
                    <For each={sessions}>
                      {(session, idx) => (
                        <button
                          class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
                            idx() !== sessions.length - 1 ? "border-b border-gray-6/50" : ""
                          }`}
                        >
                          <div class="flex items-center gap-4">
                            <div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
                              #{session.slug}
                            </div>
                            <div>
                              <div class="font-medium text-sm text-gray-12">{session.title}</div>
                              <div class="text-xs text-gray-10 flex items-center gap-2">
                                <span>{session.updated}</span>
                                <span class="text-[11px] px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-10">
                                  {workspaceById(session.workspaceId).name}
                                </span>
                              </div>
                            </div>
                          </div>
                          <div class="flex items-center gap-3">
                            <span class={`text-xs ${statusStyles[session.status]}`}>{session.status}</span>
                          </div>
                        </button>
                      )}
                    </For>
                  </div>
                </section>
              </div>
            </main>
          </div>
        </Show>

        <Show when={view() === "session"}>
          <div class="h-[70vh] flex flex-col bg-gray-1 text-gray-12 relative rounded-3xl border border-gray-6 overflow-hidden">
            <header class="h-16 border-b border-gray-6 flex items-center justify-between px-6 bg-gray-1/80 backdrop-blur-md z-10 sticky top-0">
              <div class="flex items-center gap-3">
                <Button variant="ghost" class="!p-2 rounded-full">
                  <ArrowLeft class="w-5 h-5" />
                </Button>
                <WorkspaceChip workspace={activeWorkspace()} onClick={() => undefined} />
                <span class="text-xs text-gray-10">Session: Reconcile vendor overages</span>
              </div>
              <div class="flex items-center gap-2">
                <Button variant="outline" class="text-xs">Rename</Button>
                <Button class="text-xs">New Task</Button>
              </div>
            </header>

            <div class="flex-1 flex overflow-hidden">
              <aside class="hidden lg:flex w-72 border-r border-gray-6 bg-gray-1 flex-col">
                <div class="p-4 border-b border-gray-6 text-xs text-gray-10 uppercase tracking-wider">
                  Sessions
                </div>
                <div class="p-4 space-y-2">
                  <For each={sessions}>
                    {(session) => (
                      <button class="w-full text-left rounded-xl border border-gray-6/60 bg-gray-1/40 px-3 py-2">
                        <div class="text-sm text-gray-12 truncate">{session.title}</div>
                        <div class="text-xs text-gray-10">{workspaceById(session.workspaceId).name}</div>
                      </button>
                    )}
                  </For>
                </div>
              </aside>

              <div class="flex-1 overflow-y-auto pt-6 md:pt-10 px-6 md:px-10">
                <div class="max-w-2xl mx-auto space-y-6">
                  <div class="rounded-2xl border border-gray-6/70 bg-gray-2/40 px-4 py-3 text-xs text-gray-11">
                    Thinking · Reading workspace files
                  </div>
                  <div class="space-y-4">
                    <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4">
                      <div class="text-xs text-gray-9">User</div>
                      <div class="text-sm text-gray-12">Please reconcile vendor overages for the last month.</div>
                    </div>
                    <div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 p-4">
                      <div class="text-xs text-gray-9">Assistant</div>
                      <div class="text-sm text-gray-12">
                        I matched the overages to the latest statements and flagged three anomalies.
                      </div>
                    </div>
                  </div>
                </div>
              </div>

              <aside class="hidden lg:flex w-72 border-l border-gray-6 bg-gray-1 flex-col">
                <div class="p-4 border-b border-gray-6 text-xs text-gray-10 uppercase tracking-wider">
                  Context
                </div>
                <div class="p-4 space-y-3 text-sm text-gray-10">
                  <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
                    Working files: 4
                  </div>
                  <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
                    Skills: 2
                  </div>
                  <div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-3">
                    Plugins: 1
                  </div>
                </div>
              </aside>
            </div>

            <div class="border-t border-gray-6 bg-gray-1 px-6 py-4">
              <div class="max-w-2xl mx-auto flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/40 px-4 py-3">
                <input
                  placeholder="Describe a task..."
                  class="flex-1 bg-transparent text-sm text-gray-12 placeholder-gray-7 focus:outline-none"
                />
                <button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Send</button>
              </div>
            </div>
          </div>
        </Show>
      </div>
    </main>
  );
}



================================================
FILE: packages/app/src/app/pages/session.tsx
================================================
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js";
import type { Agent, Part } from "@opencode-ai/sdk/v2/client";
import type {
  ArtifactItem,
  DashboardTab,
  ComposerDraft,
  CommandRegistryItem,
  CommandTriggerContext,
  MessageGroup,
  MessageWithParts,
  McpServerEntry,
  McpStatusMap,
  PendingPermission,
  ProviderListItem,
  SettingsTab,
  SkillCard,
  TodoItem,
  View,
  WorkspaceCommand,
  WorkspaceDisplay,
} from "../types";

import {
  AlertTriangle,
  ArrowRight,
  ChevronDown,
  HardDrive,
  Shield,
  Zap,
} from "lucide-solid";

import Button from "../components/button";
import RenameSessionModal from "../components/rename-session-modal";
import WorkspaceChip from "../components/workspace-chip";
import ProviderAuthModal from "../components/provider-auth-modal";
import StatusBar from "../components/status-bar";
import type { OpenworkServerStatus } from "../lib/openwork-server";

import MessageList from "../components/session/message-list";
import Composer from "../components/session/composer";
import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar";
import ContextPanel from "../components/session/context-panel";
import FlyoutItem from "../components/flyout-item";

export type SessionViewProps = {
  selectedSessionId: string | null;
  setView: (view: View, sessionId?: string) => void;
  setTab: (tab: DashboardTab) => void;
  setSettingsTab: (tab: SettingsTab) => void;
  activeWorkspaceDisplay: WorkspaceDisplay;
  setWorkspaceSearch: (value: string) => void;
  setWorkspacePickerOpen: (open: boolean) => void;
  mode: "host" | "client" | null;
  clientConnected: boolean;
  openworkServerStatus: OpenworkServerStatus;
  stopHost: () => void;
  headerStatus: string;
  busyHint: string | null;
  createSessionAndOpen: () => void;
  sendPromptAsync: (draft: ComposerDraft) => Promise<void>;
  newTaskDisabled: boolean;
  sessions: Array<{ id: string; title: string; slug?: string | null; workspaceLabel?: string | null }>;
  selectSession: (sessionId: string) => Promise<void> | void;
  messages: MessageWithParts[];
  todos: TodoItem[];
  busyLabel: string | null;
  developerMode: boolean;
  showThinking: boolean;
  groupMessageParts: (parts: Part[], messageId: string) => MessageGroup[];
  summarizeStep: (part: Part) => { title: string; detail?: string };
  expandedStepIds: Set<string>;
  setExpandedStepIds: (updater: (current: Set<string>) => Set<string>) => Set<string>;
  expandedSidebarSections: SidebarSectionState;
  setExpandedSidebarSections: (
    updater: (current: SidebarSectionState) => SidebarSectionState,
  ) => SidebarSectionState;
  artifacts: ArtifactItem[];
  workingFiles: string[];
  authorizedDirs: string[];
  activePlugins: string[];
  activePluginStatus: string | null;
  mcpServers: McpServerEntry[];
  mcpStatuses: McpStatusMap;
  mcpStatus: string | null;
  skills: SkillCard[];
  skillsStatus: string | null;
  busy: boolean;
  prompt: string;
  setPrompt: (value: string) => void;
  selectedSessionModelLabel: string;
  openSessionModelPicker: () => void;
  modelVariantLabel: string;
  modelVariant: string | null;
  setModelVariant: (value: string) => void;
  activePermission: PendingPermission | null;
  showTryNotionPrompt: boolean;
  onTryNotionPrompt: () => void;
  permissionReplyBusy: boolean;
  respondPermission: (requestID: string, reply: "once" | "always" | "reject") => void;
  respondPermissionAndRemember: (requestID: string, reply: "once" | "always" | "reject") => void;
  safeStringify: (value: unknown) => string;
  error: string | null;
  sessionStatus: string;
  renameSession: (sessionId: string, title: string) => Promise<void>;
  openConnect: () => void;
  startProviderAuth: (providerId?: string) => Promise<string>;
  openProviderAuthModal: () => Promise<void>;
  closeProviderAuthModal: () => void;
  providerAuthModalOpen: boolean;
  providerAuthBusy: boolean;
  providerAuthError: string | null;
  providerAuthMethods: Record<string, { type: "oauth" | "api"; label: string }[]>;
  providers: ProviderListItem[];
  providerConnectedIds: string[];
  listAgents: () => Promise<Agent[]>;
  searchFiles: (query: string) => Promise<string[]>;
  selectedSessionAgent: string | null;
  setSessionAgent: (sessionId: string, agent: string | null) => void;
  saveSession: (sessionId: string) => Promise<string>;
  sessionStatusById: Record<string, string>;
  commands: WorkspaceCommand[];
  runCommand: (command: WorkspaceCommand, details?: string) => Promise<void>;
  openCommandRunModal: (command: WorkspaceCommand) => void;
  commandRegistryItems: () => CommandRegistryItem[];
  registerCommand: (command: CommandRegistryItem) => () => void;
};

export default function SessionView(props: SessionViewProps) {
  let messagesEndEl: HTMLDivElement | undefined;
  let chatContainerEl: HTMLDivElement | undefined;
  let agentPickerRef: HTMLDivElement | undefined;

  const [commandToast, setCommandToast] = createSignal<string | null>(null);
  const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false);
  const [renameModalOpen, setRenameModalOpen] = createSignal(false);
  const [renameTitle, setRenameTitle] = createSignal("");
  const [renameBusy, setRenameBusy] = createSignal(false);
  const [agentPickerOpen, setAgentPickerOpen] = createSignal(false);
  const [agentPickerBusy, setAgentPickerBusy] = createSignal(false);
  const [agentPickerReady, setAgentPickerReady] = createSignal(false);
  const [agentPickerError, setAgentPickerError] = createSignal<string | null>(null);
  const [agentOptions, setAgentOptions] = createSignal<Agent[]>([]);

  const COMMAND_ARGS_RE = /\$(ARGUMENTS|\d+)/i;

  const commandNeedsDetails = (command: { template: string }) => COMMAND_ARGS_RE.test(command.template);

  const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent");

  const loadAgentOptions = async (force = false) => {
    if (agentPickerBusy()) return agentOptions();
    if (agentPickerReady() && !force) return agentOptions();
    setAgentPickerBusy(true);
    setAgentPickerError(null);
    try {
      const agents = await props.listAgents();
      const sorted = agents.slice().sort((a, b) => a.name.localeCompare(b.name));
      setAgentOptions(sorted);
      setAgentPickerReady(true);
      return sorted;
    } catch (error) {
      const message = error instanceof Error ? error.message : "Failed to load agents";
      setAgentPickerError(message);
      setAgentOptions([]);
      return [];
    } finally {
      setAgentPickerBusy(false);
    }
  };

  type Flyout = {
    id: string;
    rect: { top: number; left: number; width: number; height: number };
    targetRect: { top: number; left: number; width: number; height: number };
    label: string;
    icon: "file" | "check" | "folder";
  };
  const [flyouts, setFlyouts] = createSignal<Flyout[]>([]);
  const [prevTodoCount, setPrevTodoCount] = createSignal(0);
  const [prevFileCount, setPrevFileCount] = createSignal(0);
  const [isInitialLoad, setIsInitialLoad] = createSignal(true);
  const [runStartedAt, setRunStartedAt] = createSignal<number | null>(null);
  const [runHasBegun, setRunHasBegun] = createSignal(false);
  const [runTick, setRunTick] = createSignal(Date.now());
  const [runBaseline, setRunBaseline] = createSignal<{ assistantId: string | null; partCount: number }>({
    assistantId: null,
    partCount: 0,
  });
  const [thinkingExpanded, setThinkingExpanded] = createSignal(false);

  const lastAssistantSnapshot = createMemo(() => {
    for (let i = props.messages.length - 1; i >= 0; i -= 1) {
      const msg = props.messages[i];
      const info = msg?.info as { id?: string | number; role?: string } | undefined;
      if (info?.role === "assistant") {
        const id = typeof info.id === "string" ? info.id : typeof info.id === "number" ? String(info.id) : null;
        return { id, partCount: msg.parts.length };
      }
    }
    return { id: null, partCount: 0 };
  });

  const captureRunBaseline = () => {
    const snapshot = lastAssistantSnapshot();
    setRunBaseline({ assistantId: snapshot.id, partCount: snapshot.partCount });
  };

  const startRun = () => {
    if (runStartedAt()) return;
    setRunStartedAt(Date.now());
    setRunHasBegun(false);
    captureRunBaseline();
  };

  const responseStarted = createMemo(() => {
    if (!runStartedAt()) return false;
    const baseline = runBaseline();
    const snapshot = lastAssistantSnapshot();
    if (!snapshot.id && !baseline.assistantId) return false;
    if (snapshot.id && snapshot.id !== baseline.assistantId) return true;
    return snapshot.id === baseline.assistantId && snapshot.partCount > baseline.partCount;
  });

  const runPhase = createMemo(() => {
    if (props.error) return "error";
    const status = props.sessionStatus;
    const started = runStartedAt() !== null;
    if (status === "idle") {
      if (!started) return "idle";
      return responseStarted() ? "responding" : "sending";
    }
    if (status === "retry") return responseStarted() ? "responding" : "retrying";
    if (responseStarted()) return "responding";
    return "thinking";
  });

  const showRunIndicator = createMemo(() => runPhase() !== "idle");

  const latestRunPart = createMemo<Part | null>(() => {
    if (!showRunIndicator()) return null;
    const baseline = runBaseline();
    for (let i = props.messages.length - 1; i >= 0; i -= 1) {
      const msg = props.messages[i];
      const info = msg?.info as { id?: string | number; role?: string } | undefined;
      if (info?.role !== "assistant") continue;
      const messageId =
        typeof info.id === "string" ? info.id : typeof info.id === "number" ? String(info.id) : null;
      if (!messageId) continue;
      if (baseline.assistantId && messageId === baseline.assistantId && msg.parts.length <= baseline.partCount) {
        continue;
      }
      if (!msg.parts.length) continue;
      return msg.parts[msg.parts.length - 1] ?? null;
    }
    return null;
  });

  const computeStatusFromPart = (part: Part | null) => {
    if (!part) return null;
    if (part.type === "tool") {
      const record = part as any;
      const tool = typeof record.tool === "string" ? record.tool : "";
      switch (tool) {
        case "task":
          return "Delegating";
        case "todowrite":
        case "todoread":
          return "Planning";
        case "read":
          return "Gathering context";
        case "list":
        case "grep":
        case "glob":
          return "Searching codebase";
        case "webfetch":
          return "Searching the web";
        case "edit":
        case "write":
          return "Making edits";
        case "bash":
          return "Running commands";
        default:
          return "Working";
      }
    }
    if (part.type === "reasoning") {
      const text = typeof (part as any).text === "string" ? (part as any).text : "";
      const match = text.trimStart().match(/^\*\*(.+?)\*\*/);
      if (match) return `Thinking about ${match[1].trim()}`;
      return "Thinking";
    }
    if (part.type === "text") {
      return "Gathering thoughts";
    }
    return null;
  };

  const truncateDetail = (value: string, max = 240) => {
    const trimmed = value.trim();
    if (!trimmed) return null;
    if (trimmed.length <= max) return trimmed;
    return `${trimmed.slice(0, max)}...`;
  };

  const thinkingStatus = createMemo(() => {
    const status = computeStatusFromPart(latestRunPart());
    if (status) return status;
    if (runPhase() === "thinking") return "Thinking";
    return null;
  });

  const thinkingDetail = createMemo<null | { title: string; detail?: string }>(() => {
    const part = latestRunPart();
    if (!part) return null;
    if (part.type === "tool") {
      const record = part as any;
      const state = record.state ?? {};
      const title =
        typeof state.title === "string" && state.title.trim() ? state.title.trim() : String(record.tool ?? "Tool");
      const output = typeof state.output === "string" ? truncateDetail(state.output) : null;
      const error = typeof state.error === "string" ? truncateDetail(state.error) : null;
      return { title, detail: output ?? error ?? undefined };
    }
    if (part.type === "reasoning") {
      const text = typeof (part as any).text === "string" ? (part as any).text : "";
      const detail = truncateDetail(text);
      return detail ? { title: "Reasoning", detail } : { title: "Reasoning" };
    }
    if (part.type === "text") {
      const text = typeof (part as any).text === "string" ? (part as any).text : "";
      const detail = truncateDetail(text);
      return detail ? { title: "Draft", detail } : { title: "Draft" };
    }
    return null;
  });

  const runLabel = createMemo(() => {
    switch (runPhase()) {
      case "sending":
        return "Sending";
      case "retrying":
        return "Retrying";
      case "responding":
        return "Responding";
      case "thinking":
        return "Thinking";
      case "error":
        return "Run failed";
      default:
        return "";
    }
  });

  const runDetail = createMemo(() => {
    if (props.error) return props.error;
    const status = props.sessionStatus;
    if (runPhase() === "responding") return "Streaming response";
    if (status === "retry") return "Retrying the last step";
    if (status === "running") return "Working on your request";
    if (status === "idle" && runStartedAt()) return "Queued";
    return "";
  });

  const runLine = createMemo(() => {
    const label = runLabel();
    const detail = runDetail();
    if (!detail) return label;
    return `${label} · ${detail}`;
  });

  const runElapsedMs = createMemo(() => {
    const start = runStartedAt();
    if (!start) return 0;
    return Math.max(0, runTick() - start);
  });

  const runElapsedLabel = createMemo(() => `${Math.round(runElapsedMs()).toLocaleString()}ms`);

  onMount(() => {
    setTimeout(() => setIsInitialLoad(false), 2000);
  });

  createEffect(() => {
    const status = props.sessionStatus;
    if (status === "running" || status === "retry") {
      startRun();
      setRunHasBegun(true);
    }
  });

  createEffect(() => {
    if (responseStarted()) {
      setRunHasBegun(true);
    }
  });

  createEffect(() => {
    if (!runStartedAt()) return;
    if (props.sessionStatus === "idle" && runHasBegun() && !props.error) {
      setRunStartedAt(null);
      setRunHasBegun(false);
      setRunBaseline({ assistantId: null, partCount: 0 });
    }
  });

  createEffect(() => {
    if (!showRunIndicator()) return;
    setRunTick(Date.now());
    const id = window.setInterval(() => setRunTick(Date.now()), 50);
    onCleanup(() => window.clearInterval(id));
  });

  createEffect(() => {
    if (!thinkingStatus()) {
      setThinkingExpanded(false);
    }
  });

  createEffect(
    on(
      () => [
        props.messages.length,
        props.todos.length,
        props.messages.reduce((acc, m) => acc + m.parts.length, 0),
      ],
      (current, previous) => {
        if (!previous) return;
        const [mLen, tLen, pCount] = current;
        const [prevM, prevT, prevP] = previous;
        if (mLen > prevM || tLen > prevT || pCount > prevP) {
          messagesEndEl?.scrollIntoView({ behavior: "smooth" });
        }
      },
    ),
  );

  const triggerFlyout = (
    sourceEl: Element | null,
    targetId: string,
    label: string,
    icon: Flyout["icon"]
  ) => {
    if (isInitialLoad() || !sourceEl) return;
    const targetEl = document.getElementById(targetId);
    if (!targetEl) return;

    const rect = sourceEl.getBoundingClientRect();
    const targetRect = targetEl.getBoundingClientRect();

    const id = Math.random().toString(36);
    setFlyouts((prev) => [
      ...prev,
      {
        id,
        rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
        targetRect: { top: targetRect.top, left: targetRect.left, width: targetRect.width, height: targetRect.height },
        label,
        icon,
      },
    ]);

    setTimeout(() => {
      setFlyouts((prev) => prev.filter((f) => f.id !== id));
    }, 1000);
  };

  createEffect(() => {
    const todos = props.todos.filter((t) => t.content.trim());
    const count = todos.length;
    const prev = prevTodoCount();
    if (count > prev && prev > 0) {
      const lastMsg = chatContainerEl?.querySelector('[data-message-role="assistant"]:last-child');
      triggerFlyout(lastMsg ?? null, "sidebar-progress", "New Task", "check");
    }
    setPrevTodoCount(count);
  });

  createEffect(() => {
     const files = props.workingFiles;
     const count = files.length;
     const prev = prevFileCount();
     if (count > prev && prev > 0) {
        const lastMsg = chatContainerEl?.querySelector('[data-message-role="assistant"]:last-child');
        triggerFlyout(lastMsg ?? null, "sidebar-context", "File Modified", "folder");
     }
     setPrevFileCount(count);
  });

  createEffect(() => {
    if (!commandToast()) return;
    const id = window.setTimeout(() => setCommandToast(null), 2400);
    return () => window.clearTimeout(id);
  });

  const selectedSessionTitle = createMemo(() => {
    const id = props.selectedSessionId;
    if (!id) return "";
    return props.sessions.find((session) => session.id === id)?.title ?? "";
  });


  const renameCanSave = createMemo(() => {
    if (renameBusy()) return false;
    const next = renameTitle().trim();
    if (!next) return false;
    return next !== selectedSessionTitle().trim();
  });

  const openRenameModal = () => {
    if (!props.selectedSessionId) {
      setCommandToast("No session selected");
      return;
    }
    setRenameTitle(selectedSessionTitle());
    setRenameModalOpen(true);
  };

  const closeRenameModal = () => {
    if (renameBusy()) return;
    setRenameModalOpen(false);
  };

  const submitRename = async () => {
    const sessionId = props.selectedSessionId;
    if (!sessionId) return;
    const next = renameTitle().trim();
    if (!next || !renameCanSave()) return;
    setRenameBusy(true);
    try {
      await props.renameSession(sessionId, next);
      setRenameModalOpen(false);
    } catch (error) {
      const message = error instanceof Error ? error.message : props.safeStringify(error);
      setCommandToast(message);
    } finally {
      setRenameBusy(false);
    }
  };

  const clearPrompt = () => props.setPrompt("");

  const extractCommandArgs = (raw: string) => {
    const trimmed = raw.trim();
    if (!trimmed.startsWith("/")) return "";
    const body = trimmed.slice(1).trim();
    const spaceIndex = body.indexOf(" ");
    if (spaceIndex === -1) return "";
    return body.slice(spaceIndex + 1).trim();
  };

  const requireSessionId = () => {
    const sessionId = props.selectedSessionId;
    if (!sessionId) {
      setCommandToast("No session selected");
      return null;
    }
    return sessionId;
  };

  const formatListHint = (items: string[]) => {
    if (!items.length) return "";
    const preview = items.slice(0, 4).join(", ");
    return items.length > 4 ? `${preview}, ...` : preview;
  };

  const MODEL_VARIANT_OPTIONS = ["none", "low", "medium", "high", "xhigh"];

  const normalizeVariantInput = (value: string) => {
    const trimmed = value.trim().toLowerCase();
    if (trimmed === "balance" || trimmed === "balanced") return "none";
    return MODEL_VARIANT_OPTIONS.includes(trimmed) ? trimmed : null;
  };

  const openAgentPicker = () => {
    setAgentPickerOpen((current) => !current);
    if (!agentPickerReady()) {
      void loadAgentOptions();
    }
  };

  const applySessionAgent = (agent: string | null) => {
    const sessionId = requireSessionId();
    if (!sessionId) return;
    props.setSessionAgent(sessionId, agent);
  };

  const cycleAgent = async (direction: "next" | "prev") => {
    const sessionId = requireSessionId();
    if (!sessionId) return;
    try {
      const agents = await loadAgentOptions(true);
      if (!agents.length) {
        setCommandToast("No agents available");
        return;
      }
      const names = agents.map((agent) => agent.name);
      const current = props.selectedSessionAgent ?? "";
      const currentIndex = current ? names.findIndex((name) => name === current) : -1;
      let nextIndex = 0;
      if (currentIndex === -1) {
        nextIndex = direction === "next" ? 0 : names.length - 1;
      } else if (direction === "next") {
        nextIndex = (currentIndex + 1) % names.length;
      } else {
        nextIndex = (currentIndex - 1 + names.length) % names.length;
      }
      const nextAgent = names[nextIndex] ?? null;
      if (!nextAgent) {
        setCommandToast("No agents available");
        return;
      }
      props.setSessionAgent(sessionId, nextAgent);
    } catch (error) {
      const message = error instanceof Error ? error.message : "Agent selection failed";
      setCommandToast(message);
    }
  };

  createEffect(() => {
    if (!agentPickerOpen()) return;
    const handler = (event: MouseEvent) => {
      if (!agentPickerRef) return;
      if (agentPickerRef.contains(event.target as Node)) return;
      setAgentPickerOpen(false);
    };
    window.addEventListener("mousedown", handler);
    onCleanup(() => window.removeEventListener("mousedown", handler));
  });

  const handleProviderAuthSelect = async (providerId: string) => {
    if (providerAuthActionBusy()) return;
    setProviderAuthActionBusy(true);
    try {
      const message = await props.startProviderAuth(providerId);
      setCommandToast(message || "Auth flow started");
      props.closeProviderAuthModal();
    } catch (error) {
      const message = error instanceof Error ? error.message : "Auth failed";
      setCommandToast(message);
    } finally {
      setProviderAuthActionBusy(false);
    }
  };

  const runOpenCodeCommand = (command: WorkspaceCommand, context?: CommandTriggerContext) => {
    const details = context?.source === "slash" ? extractCommandArgs(props.prompt) : "";
    const shouldClear = context?.source === "slash";

    if (details) {
      void props.runCommand(command, details);
      if (shouldClear) clearPrompt();
      return;
    }

    if (commandNeedsDetails(command)) {
      props.openCommandRunModal(command);
      if (shouldClear) clearPrompt();
      return;
    }

    void props.runCommand(command);
    if (shouldClear) clearPrompt();
  };

  const buildHelpPreview = () => {
    const commands = slashCommands().map((command) => `/${command.slash}`);
    return formatListHint(commands);
  };
  const registerSessionCommands = () => {
    const commands: CommandRegistryItem[] = [
      {
        id: "session.models",
        title: "Choose a model",
        category: "Session",
        description: "Choose a model",
        slash: "models",
        scope: "session",
        onSelect: () => {
          props.openSessionModelPicker();
          clearPrompt();
        },
      },
      {
        id: "session.connect",
        title: "Connect a provider",
        category: "Session",
        description: "Connect a provider",
        slash: "connect",
        scope: "session",
        onSelect: async () => {
          try {
            await props.openProviderAuthModal();
            setCommandToast("Select a provider to connect");
            clearPrompt();
          } catch (error) {
            const message = error instanceof Error ? error.message : "Connect failed";
            setCommandToast(message);
          }
        },
      },
      {
        id: "session.variant",
        title: "Change model variant",
        category: "Session",
        description: "Adjust the model variant",
        slash: "variant",
        scope: "session",
        onSelect: () => {
          const rawArg = extractCommandArgs(props.prompt);
          if (!rawArg) {
            setCommandToast(`Use /variant ${MODEL_VARIANT_OPTIONS.join("/")}`);
            return;
          }
          const normalized = normalizeVariantInput(rawArg);
          if (!normalized) {
            setCommandToast(`Variant must be: ${MODEL_VARIANT_OPTIONS.join(", ")}`);
            return;
          }
          props.setModelVariant(normalized);
          setCommandToast(`Variant set to ${normalized}`);
          clearPrompt();
        },
      },
      {
        id: "session.new",
        title: "Start a new task",
        category: "Session",
        description: "Start a new task",
        slash: "new",
        scope: "session",
        onSelect: () => {
          props.createSessionAndOpen();
          clearPrompt();
        },
      },
      {
        id: "session.agent",
        title: "Choose an agent",
        category: "Session",
        description: "Choose an agent",
        slash: "agent",
        scope: "session",
        onSelect: async () => {
          const sessionId = requireSessionId();
          if (!sessionId) return;

          try {
            const rawArg = extractCommandArgs(props.prompt);
            if (/^(next|prev|previous)$/i.test(rawArg)) {
              await cycleAgent(/^prev/i.test(rawArg) ? "prev" : "next");
              clearPrompt();
              return;
            }
            if (/^(none|clear|default)$/i.test(rawArg)) {
              props.setSessionAgent(sessionId, null);
              setCommandToast("Agent cleared");
              clearPrompt();
              return;
            }

            const agents = await props.listAgents();
            if (!agents.length) {
              setCommandToast("No agents available");
              clearPrompt();
              return;
            }

            const agentNames = agents.map((agent) => agent.name);
            let candidate = rawArg;
            if (!candidate) {
              const hint = formatListHint(agentNames);
              const promptLabel = hint ? `Agent name (e.g. ${hint})` : "Agent name";
              const prompted = window.prompt(promptLabel, agentNames[0] ?? "");
              if (prompted == null) return;
              candidate = prompted.trim();
            }

            if (!candidate) {
              setCommandToast("Agent name is required");
              clearPrompt();
              return;
            }

            const match = agents.find(
              (agent) => agent.name.toLowerCase() === candidate.toLowerCase(),
            );
            if (!match) {
              setCommandToast(`Unknown agent. Available: ${formatListHint(agentNames)}`);
              clearPrompt();
              return;
            }

              props.setSessionAgent(sessionId, match.name);
            clearPrompt();
          } catch (error) {
            const message = error instanceof Error ? error.message : "Agent selection failed";
            setCommandToast(message);
          }
        },
      },
      {
        id: "session.agent.next",
        title: "Next agent",
        category: "Session",
        description: "Cycle to the next agent",
        slash: "agent-next",
        scope: "session",
        onSelect: async () => {
          await cycleAgent("next");
          clearPrompt();
        },
      },
      {
        id: "session.agent.prev",
        title: "Previous agent",
        category: "Session",
        description: "Cycle to the previous agent",
        slash: "agent-prev",
        scope: "session",
        onSelect: async () => {
          await cycleAgent("prev");
          clearPrompt();
        },
      },
      {
        id: "session.export",
        title: "Export session JSON",
        category: "Session",
        description: "Export session JSON",
        slash: "export",
        scope: "session",
        onSelect: async () => {
          const sessionId = requireSessionId();
          if (!sessionId) return;

          try {
            const fileName = await props.saveSession(sessionId);
            setCommandToast(`Exported ${fileName}`);
            clearPrompt();
          } catch (error) {
            const message = error instanceof Error ? error.message : "Export failed";
            setCommandToast(message);
          }
        },
      },
      {
        id: "session.rename",
        title: "Rename this session",
        category: "Session",
        description: "Rename this session",
        slash: "rename",
        scope: "session",
        onSelect: () => {
          openRenameModal();
          clearPrompt();
        },
      },
      {
        id: "session.help",
        title: "Show available commands",
        category: "Session",
        description: "Show available commands",
        slash: "help",
        scope: "session",
        onSelect: () => {
          const preview = buildHelpPreview();
          setCommandToast(preview ? `Commands: ${preview}` : "No commands available");
          clearPrompt();
        },
      },
    ];

    const cleanups = commands.map((command) => props.registerCommand(command));
    onCleanup(() => cleanups.forEach((cleanup) => cleanup()));
  };

  createEffect(() => {
    registerSessionCommands();
  });

  createEffect(() => {
    const cleanups = props.commands.map((command) =>
      props.registerCommand({
        id: `command.${command.name}`,
        title: `/${command.name}`,
        category: "Commands",
        description: command.description || "Run a saved command",
        slash: command.name,
        scope: "session",
        onSelect: (context) => runOpenCodeCommand(command, context),
      }),
    );
    onCleanup(() => cleanups.forEach((cleanup) => cleanup()));
  });

  const slashCommands = createMemo(() =>
    props
      .commandRegistryItems()
      .filter((command) => command.slash)
      .sort((a, b) => (a.slash ?? "").localeCompare(b.slash ?? "")),
  );

  const slashCommandIndex = createMemo(() => {
    const map = new Map<string, CommandRegistryItem>();
    for (const command of slashCommands()) {
      if (command.slash) map.set(command.slash, command);
    }
    return map;
  });

  const commandNeedsArgs = createMemo(() => {
    const map = new Map<string, boolean>();
    for (const command of props.commands) {
      map.set(command.name, commandNeedsDetails(command));
    }
    return map;
  });

  const commandMatches = createMemo(() => {
    const value = props.prompt;
    if (!value.startsWith("/")) return [];
    const token = value.slice(1).trim().split(/\s+/)[0]?.toLowerCase() ?? "";
    const list = slashCommands();
    const matches = token
      ? list.filter((command) => command.slash?.toLowerCase().startsWith(token))
      : list;
    return matches.map((command) => ({
      id: command.slash!,
      description: command.description || "Run a command",
      needsArgs: commandNeedsArgs().get(command.slash ?? "") ?? false,
    }));
  });

  const handleRunCommand = (commandId: string) => {
    const command = slashCommandIndex().get(commandId);
    if (command) {
      command.onSelect({ source: "slash" });
    }
  };

  const handleInsertCommand = (commandId: string) => {
    props.setPrompt(`/${commandId} `);
    window.dispatchEvent(new CustomEvent("openwork:focusPrompt"));
  };

  const handleSendPrompt = (draft: ComposerDraft) => {
    const trimmed = draft.text.trim();
    if (draft.mode === "prompt" && trimmed.startsWith("/")) {
      const active = commandMatches()[0];
      if (active) {
        const args = extractCommandArgs(trimmed);
        if (active.needsArgs && !args) {
          handleInsertCommand(active.id);
        } else {
          handleRunCommand(active.id);
        }
      }
      return;
    }

    startRun();
    props.sendPromptAsync(draft).catch(() => undefined);
  };

  const handleDraftChange = (draft: ComposerDraft) => {
    props.setPrompt(draft.text);
  };

  const openSettings = (tab: SettingsTab = "general") => {
    props.setSettingsTab(tab);
    props.setTab("settings");
    props.setView("dashboard");
  };

  return (
    <div class="h-screen flex flex-col bg-gray-1 text-gray-12 relative pb-16 md:pb-12">
        <header class="h-16 border-b border-gray-6 flex items-center justify-between px-6 bg-gray-1/80 backdrop-blur-md z-10 sticky top-0">
          <div class="flex items-center gap-3">
            <Button
              variant="ghost"
              class="!p-2 rounded-full"
              onClick={() => {
                props.setTab("sessions");
                props.setView("dashboard");
              }}
            >
              <ArrowRight class="rotate-180 w-5 h-5" />
            </Button>
             <WorkspaceChip
               workspace={props.activeWorkspaceDisplay}
               onClick={() => {
                 props.setWorkspaceSearch("");
                 props.setWorkspacePickerOpen(true);
               }}
             />
             <Show when={props.developerMode}>
               <span class="text-xs text-gray-7">{props.headerStatus}</span>
             </Show>
             <Show when={props.busyHint}>
               <span class="text-xs text-gray-10">· {props.busyHint}</span>
             </Show>

          </div>
        </header>

        <Show when={props.error}>
          <div class="mx-auto max-w-5xl w-full px-6 md:px-10 pt-4">
            <div class="rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20">
              {props.error}
            </div>
          </div>
        </Show>

        <div class="flex-1 flex overflow-hidden">
          <aside class="hidden lg:flex w-72 border-r border-gray-6 bg-gray-1 flex-col">
             <SessionSidebar
               todos={props.todos}
               expandedSections={props.expandedSidebarSections}
               onToggleSection={(section) => {
                 props.setExpandedSidebarSections((curr) => ({...curr, [section]: !curr[section]}));
               }}
               sessions={props.sessions}
               selectedSessionId={props.selectedSessionId}
                onSelectSession={async (id) => {
                  await props.selectSession(id);
                  props.setView("session", id);
                  props.setTab("sessions");
                }}
               sessionStatusById={props.sessionStatusById}
               onCreateSession={props.createSessionAndOpen}
               newTaskDisabled={props.newTaskDisabled}
             />
          </aside>

          <div
            class="flex-1 overflow-y-auto pt-6 md:pt-10 scroll-smooth relative"
            ref={(el) => (chatContainerEl = el)}
          >
            <Show when={props.messages.length === 0}>
              <div class="text-center py-16 px-6 space-y-6">
                <div class="w-16 h-16 bg-gray-2 rounded-3xl mx-auto flex items-center justify-center border border-gray-6">
                  <Zap class="text-gray-7" />
                </div>
                <div class="space-y-2">
                  <h3 class="text-xl font-medium">Start a conversation</h3>
                  <p class="text-gray-10 text-sm max-w-sm mx-auto">
                    Describe what you want to do, and OpenWork will take it from there.
                  </p>
                </div>
              </div>
            </Show>

            <MessageList 
              messages={props.messages}
              developerMode={props.developerMode}
              showThinking={props.showThinking}
              expandedStepIds={props.expandedStepIds}
              setExpandedStepIds={props.setExpandedStepIds}
              footer={
                showRunIndicator() ? (
                  <div class="flex justify-start pl-2">
                    <div class="w-full max-w-[68ch] space-y-2">
                      <Show when={thinkingStatus()}>
                        <div class="rounded-xl border border-gray-6/70 bg-gray-2/40 px-3 py-2 text-xs text-gray-11">
                          <button
                            type="button"
                            class="w-full flex items-center justify-between gap-3 text-left"
                            onClick={() => setThinkingExpanded((prev) => !prev)}
                            aria-expanded={thinkingExpanded()}
                          >
                            <div class="flex items-center gap-2 min-w-0">
                              <span class="text-[10px] uppercase tracking-wide text-gray-9">Thinking</span>
                              <span class="truncate text-gray-12">{thinkingStatus()}</span>
                            </div>
                            <ChevronDown
                              size={12}
                              class={`text-gray-8 transition-transform ${thinkingExpanded() ? "rotate-180" : ""}`}
                            />
                          </button>
                          <Show when={thinkingExpanded() && thinkingDetail()}>
                            {(detail) => (
                              <div class="mt-2 text-xs text-gray-11">
                                <div class="text-gray-12">{detail().title}</div>
                                <Show when={detail().detail}>
                                  <div class="mt-1 whitespace-pre-wrap text-gray-10">{detail().detail}</div>
                                </Show>
                              </div>
                            )}
                          </Show>
                        </div>
                      </Show>
                      <div
                        class={`w-full flex items-center justify-between gap-3 text-xs font-mono ${
                          runPhase() === "error" ? "text-red-11" : "text-gray-9"
                        }`}
                        role="status"
                        aria-live="polite"
                      >
                        <div class="flex items-center gap-2 min-w-0">
                          <Show
                            when={runPhase() !== "error"}
                            fallback={<AlertTriangle size={12} class="shrink-0" />}
                          >
                            <Zap size={12} class="shrink-0" />
                          </Show>
                          <span class="truncate">{runLine()}</span>
                        </div>
                        <span class="shrink-0">{runElapsedLabel()}</span>
                      </div>
                    </div>
                  </div>
                ) : undefined
              }
            />

            <div ref={(el) => (messagesEndEl = el)} />
          </div>

          <aside class="hidden lg:flex w-72 border-l border-gray-6 bg-gray-1 flex-col">
            <ContextPanel
              activePlugins={props.activePlugins}
              activePluginStatus={props.activePluginStatus}
              mcpServers={props.mcpServers}
              mcpStatuses={props.mcpStatuses}
              mcpStatus={props.mcpStatus}
              skills={props.skills}
              skillsStatus={props.skillsStatus}
              authorizedDirs={props.authorizedDirs}
              workingFiles={props.workingFiles}
              expandedSections={props.expandedSidebarSections}
              onToggleSection={(section) =>
                props.setExpandedSidebarSections((curr) => ({
                  ...curr,
                  [section]: !curr[section],
                }))
              }
            />
          </aside>
        </div>

        <Composer
          prompt={props.prompt}
          busy={props.busy}
          onSend={handleSendPrompt}
          onDraftChange={handleDraftChange}
          commandMatches={commandMatches()}
          onRunCommand={handleRunCommand}
          onInsertCommand={handleInsertCommand}
          selectedModelLabel={props.selectedSessionModelLabel || "Model"}
          onModelClick={props.openSessionModelPicker}
          modelVariantLabel={props.modelVariantLabel}
          modelVariant={props.modelVariant}
          onModelVariantChange={props.setModelVariant}
          agentLabel={agentLabel()}
          selectedAgent={props.selectedSessionAgent}
          agentPickerOpen={agentPickerOpen()}
          agentPickerBusy={agentPickerBusy()}
          agentPickerError={agentPickerError()}
          agentOptions={agentOptions()}
          onToggleAgentPicker={openAgentPicker}
          onSelectAgent={(agent) => {
            applySessionAgent(agent);
            setAgentPickerOpen(false);
          }}
          setAgentPickerRef={(el) => {
            agentPickerRef = el;
          }}
          showNotionBanner={props.showTryNotionPrompt}
          onNotionBannerClick={props.onTryNotionPrompt}
          toast={commandToast()}
          onToast={(message) => setCommandToast(message)}
          listAgents={props.listAgents}
          recentFiles={props.workingFiles}
          searchFiles={props.searchFiles}
          isRemoteWorkspace={props.activeWorkspaceDisplay.workspaceType === "remote"}
        />

        <div class="fixed bottom-0 left-0 right-0">
          <StatusBar
            mode={props.mode}
            busy={props.busy}
            stopHost={props.stopHost}
            clientConnected={props.clientConnected}
            openworkServerStatus={props.openworkServerStatus}
            developerMode={props.developerMode}
            onOpenSettings={() => openSettings("general")}
            onOpenMessaging={() => openSettings("messaging")}
          />
        </div>

        <ProviderAuthModal
          open={props.providerAuthModalOpen}
          loading={props.providerAuthBusy}
          submitting={providerAuthActionBusy()}
          error={props.providerAuthError}
          providers={props.providers}
          connectedProviderIds={props.providerConnectedIds}
          authMethods={props.providerAuthMethods}
          onSelect={handleProviderAuthSelect}
          onClose={props.closeProviderAuthModal}
        />

        <RenameSessionModal
          open={renameModalOpen()}
          title={renameTitle()}
          busy={renameBusy()}
          canSave={renameCanSave()}
          onClose={closeRenameModal}
          onSave={submitRename}
          onTitleChange={setRenameTitle}
        />

        <Show when={props.activePermission}>
          <div class="absolute inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
            <div class="bg-gray-2 border border-amber-7/30 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
              <div class="p-6">
                <div class="flex items-start gap-4 mb-4">
                  <div class="p-3 bg-amber-7/10 rounded-full text-amber-6">
                    <Shield size={24} />
                  </div>
                  <div>
                    <h3 class="text-lg font-semibold text-gray-12">Permission Required</h3>
                    <p class="text-sm text-gray-11 mt-1">OpenCode is requesting permission to continue.</p>
                  </div>
                </div>

                <div class="bg-gray-1/50 rounded-xl p-4 border border-gray-6 mb-6">
                  <div class="text-xs text-gray-10 uppercase tracking-wider mb-2 font-semibold">Permission</div>
                  <div class="text-sm text-gray-12 font-mono">{props.activePermission?.permission}</div>

                  <div class="text-xs text-gray-10 uppercase tracking-wider mt-4 mb-2 font-semibold">Scope</div>
                  <div class="flex items-center gap-2 text-sm font-mono text-amber-12 bg-amber-1/30 px-2 py-1 rounded border border-amber-7/20">
                    <HardDrive size={12} />
                    {props.activePermission?.patterns.join(", ")}
                  </div>

                  <Show when={Object.keys(props.activePermission?.metadata ?? {}).length > 0}>
                    <details class="mt-4 rounded-lg bg-gray-1/20 p-2">
                      <summary class="cursor-pointer text-xs text-gray-11">Details</summary>
                      <pre class="mt-2 whitespace-pre-wrap break-words text-xs text-gray-12">
                        {props.safeStringify(props.activePermission?.metadata)}
                      </pre>
                    </details>
                  </Show>
                </div>

                <div class="grid grid-cols-2 gap-3">
                    <Button
                      variant="outline"
                      class="w-full border-red-7/20 text-red-11 hover:bg-red-1/30"
                      onClick={() =>
                        props.activePermission && props.respondPermission(props.activePermission.id, "reject")
                      }
                      disabled={props.permissionReplyBusy}
                    >

                    Deny
                  </Button>
                  <div class="grid grid-cols-2 gap-2">
                    <Button
                      variant="secondary"
                      class="text-xs"
                      onClick={() => props.activePermission && props.respondPermission(props.activePermission.id, "once")}
                      disabled={props.permissionReplyBusy}
                    >
                      Once
                    </Button>
                    <Button
                      variant="primary"
                      class="text-xs font-bold bg-amber-7 hover:bg-amber-8 text-gray-12 border-none shadow-amber-6/20"
                      onClick={() =>
                        props.activePermission &&
                        props.respondPermissionAndRemember(props.activePermission.id, "always")
                      }
                      disabled={props.permissionReplyBusy}
                    >
                      Allow for session
                    </Button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </Show>

        <For each={flyouts()}>
          {(item) => <FlyoutItem item={item} />}
        </For>
    </div>
  );
}



================================================
FILE: packages/app/src/app/pages/skills.tsx
================================================
import { For, Show, createMemo, createSignal } from "solid-js";

import type { SkillCard } from "../types";

import Button from "../components/button";
import { FolderOpen, Package, Upload } from "lucide-solid";
import { currentLocale, t } from "../../i18n";

export type SkillsViewProps = {
  busy: boolean;
  mode: "host" | "client" | null;
  canInstallSkillCreator: boolean;
  canUseDesktopTools: boolean;
  accessHint?: string | null;
  refreshSkills: (options?: { force?: boolean }) => void;
  skills: SkillCard[];
  skillsStatus: string | null;
  importLocalSkill: () => void;
  installSkillCreator: () => void;
  revealSkillsFolder: () => void;
  uninstallSkill: (name: string) => void;
};

export default function SkillsView(props: SkillsViewProps) {
  // Translation helper that uses current language from i18n
  const translate = (key: string) => t(key, currentLocale());

  const skillCreatorInstalled = createMemo(() =>
    props.skills.some((skill) => skill.name === "skill-creator")
  );

  const [uninstallTarget, setUninstallTarget] = createSignal<SkillCard | null>(null);
  const uninstallOpen = createMemo(() => uninstallTarget() != null);

  return (
    <section class="space-y-8">
      <div class="flex flex-wrap items-start justify-between gap-4">
        <div>
          <h3 class="text-lg font-semibold text-gray-12">{translate("skills.title")}</h3>
          <p class="text-sm text-gray-10 mt-1">{translate("skills.subtitle")}</p>
        </div>
        <Button variant="secondary" onClick={() => props.refreshSkills({ force: true })} disabled={props.busy}>
          {translate("skills.refresh")}
        </Button>
      </div>

      <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 overflow-hidden">
        <div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-gray-6/60 bg-gray-2/40">
          <div>
            <div class="text-xs font-semibold text-gray-11 uppercase tracking-wider">{translate("skills.add_title")}</div>
            <div class="text-sm text-gray-10 mt-2">{translate("skills.add_description")}</div>
          </div>
          <Show when={props.accessHint}>
            <div class="text-xs text-gray-10">{props.accessHint}</div>
          </Show>
          <Show
            when={
              !props.accessHint &&
              props.mode !== "host" &&
              !props.canInstallSkillCreator &&
              !props.canUseDesktopTools
            }
          >
            <div class="text-xs text-gray-10">{translate("skills.host_mode_only")}</div>
          </Show>
        </div>

        <div class="divide-y divide-gray-6/60">
          <div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
            <div>
              <div class="text-sm font-medium text-gray-12">{translate("skills.install_skill_creator")}</div>
              <div class="text-xs text-gray-10 mt-1">{translate("skills.install_skill_creator_hint")}</div>
            </div>
            <Button
              variant={skillCreatorInstalled() ? "outline" : "secondary"}
              onClick={() => {
                if (skillCreatorInstalled()) return;
                props.installSkillCreator();
              }}
              disabled={props.busy || skillCreatorInstalled() || !props.canInstallSkillCreator}
            >
              <Package size={16} />
              {skillCreatorInstalled() ? translate("skills.installed_label") : translate("skills.install")}
            </Button>
          </div>

          <div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
            <div>
              <div class="text-sm font-medium text-gray-12">{translate("skills.import_local")}</div>
              <div class="text-xs text-gray-10 mt-1">{translate("skills.import_local_hint")}</div>
            </div>
            <Button
              variant="secondary"
              onClick={props.importLocalSkill}
              disabled={props.busy || !props.canUseDesktopTools}
            >
              <Upload size={16} />
              {translate("skills.import")}
            </Button>
          </div>

          <div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
            <div>
              <div class="text-sm font-medium text-gray-12">{translate("skills.reveal_folder")}</div>
              <div class="text-xs text-gray-10 mt-1">{translate("skills.reveal_folder_hint")}</div>
            </div>
            <Button
              variant="secondary"
              onClick={props.revealSkillsFolder}
              disabled={props.busy || !props.canUseDesktopTools}
            >
              <FolderOpen size={16} />
              {translate("skills.reveal_button")}
            </Button>
          </div>
        </div>

        <Show when={props.skillsStatus}>
          <div class="border-t border-gray-6/60 px-5 py-3 text-xs text-gray-11 whitespace-pre-wrap break-words">
            {props.skillsStatus}
          </div>
        </Show>
      </div>

      <div>
        <div class="flex items-center justify-between mb-3">
          <div>
            <div class="text-sm font-semibold text-gray-12">{translate("skills.installed")}</div>
            <div class="text-xs text-gray-10 mt-1">{translate("skills.installed_description")}</div>
          </div>
          <div class="text-xs text-gray-10">{props.skills.length}</div>
        </div>

        <Show
          when={props.skills.length}
          fallback={
            <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 px-5 py-6 text-sm text-zinc-500">
              {translate("skills.no_skills")}
            </div>
          }
        >
          <div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 divide-y divide-gray-6/60">
            <For each={props.skills}>
              {(s) => (
                <div class="px-5 py-4">
                  <div class="flex flex-wrap items-start justify-between gap-3">
                    <div class="space-y-2">
                      <div class="flex items-center gap-2">
                        <Package size={16} class="text-gray-11" />
                        <div class="font-medium text-gray-12">{s.name}</div>
                      </div>
                      <Show when={s.description}>
                        <div class="text-sm text-gray-10">{s.description}</div>
                      </Show>
                      <div class="text-xs text-gray-7 font-mono">{s.path}</div>
                    </div>
                    <Button
                      variant="danger"
                      class="!px-3 !py-2 text-xs"
                      onClick={() => setUninstallTarget(s)}
                      disabled={props.busy || !props.canUseDesktopTools}
                      title={translate("skills.uninstall")}
                    >
                      {translate("skills.uninstall")}
                    </Button>
                  </div>
                </div>
              )}
            </For>
          </div>
        </Show>
      </div>

      <Show when={uninstallOpen()}>
        <div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
          <div class="bg-gray-2 border border-gray-6/70 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
            <div class="p-6">
              <div class="flex items-start justify-between gap-4">
                <div>
                  <h3 class="text-lg font-semibold text-gray-12">{translate("skills.uninstall_title")}</h3>
                  <p class="text-sm text-gray-11 mt-1">
                    {translate("skills.uninstall_warning").replace("{name}", uninstallTarget()?.name ?? "")}
                  </p>
                </div>
              </div>

              <div class="mt-4 rounded-xl bg-gray-1/20 border border-gray-6 p-3 text-xs text-gray-11 font-mono break-all">
                {uninstallTarget()?.path}
              </div>

              <div class="mt-6 flex justify-end gap-2">
                <Button variant="outline" onClick={() => setUninstallTarget(null)} disabled={props.busy}>
                  {translate("common.cancel")}
                </Button>
                <Button
                  variant="danger"
                  onClick={() => {
                    const target = uninstallTarget();
                    setUninstallTarget(null);
                    if (!target) return;
                    props.uninstallSkill(target.name);
                  }}
                  disabled={props.busy}
                >
                  {translate("skills.uninstall")}
                </Button>
              </div>
            </div>
          </div>
        </div>
      </Show>
    </section>
  );
}



================================================
FILE: packages/app/src/app/state/extensions.ts
================================================
export { createExtensionsStore } from "../context/extensions";



================================================
FILE: packages/app/src/app/state/sessions.ts
================================================
export { createSessionStore } from "../context/session";



================================================
FILE: packages/app/src/app/state/system.ts
================================================
export { createSystemState } from "../system-state";



================================================
FILE: packages/app/src/app/utils/commands.ts
================================================
type CommandDraft = {
  name: string;
  description: string;
  template: string;
  scope: "workspace" | "global";
};

type CommandDraftSetters = {
  setName: (value: string) => void;
  setDescription: (value: string) => void;
  setTemplate: (value: string) => void;
  setScope: (value: "workspace" | "global") => void;
};

export function resetCommandDraft(setters: CommandDraftSetters, scope: "workspace" | "global" = "workspace") {
  setters.setName("");
  setters.setDescription("");
  setters.setTemplate("");
  setters.setScope(scope);
}

export function buildCommandDraft(params: {
  seedName?: string;
  seedTemplate?: string;
  scope?: "workspace" | "global";
}): CommandDraft {
  return {
    name: params.seedName ?? "",
    description: "",
    template: params.seedTemplate ?? "",
    scope: params.scope ?? "workspace",
  };
}



================================================
FILE: packages/app/src/app/utils/index.ts
================================================
import type { Part, Session } from "@opencode-ai/sdk/v2/client";
import type {
  ArtifactItem,
  MessageGroup,
  MessageInfo,
  MessageWithParts,
  ModelRef,
  OpencodeEvent,
  PlaceholderAssistantMessage,
  ProviderListItem,
} from "../types";

export function formatModelRef(model: ModelRef) {
  return `${model.providerID}/${model.modelID}`;
}

export function parseModelRef(raw: string | null): ModelRef | null {
  if (!raw) return null;
  const trimmed = raw.trim();
  if (!trimmed) return null;
  const [providerID, ...rest] = trimmed.split("/");
  if (!providerID || rest.length === 0) return null;
  return { providerID, modelID: rest.join("/") };
}

export function modelEquals(a: ModelRef, b: ModelRef) {
  return a.providerID === b.providerID && a.modelID === b.modelID;
}

const FRIENDLY_PROVIDER_LABELS: Record<string, string> = {
  opencode: "OpenCode",
  openai: "OpenAI",
  anthropic: "Anthropic",
  google: "Google",
};

const humanizeModelLabel = (value: string) => {
  const normalized = value.trim().toLowerCase();
  if (normalized && FRIENDLY_PROVIDER_LABELS[normalized]) {
    return FRIENDLY_PROVIDER_LABELS[normalized];
  }

  const cleaned = value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
  if (!cleaned) return value;

  return cleaned
    .split(" ")
    .filter(Boolean)
    .map((word) => {
      if (/\d/.test(word) || word.length <= 3) {
        return word.toUpperCase();
      }
      const lower = word.toLowerCase();
      return lower.charAt(0).toUpperCase() + lower.slice(1);
    })
    .join(" ");
};

export function formatModelLabel(model: ModelRef, providers: ProviderListItem[] = []) {
  const provider = providers.find((p) => p.id === model.providerID);
  const modelInfo = provider?.models?.[model.modelID];

  const providerLabel = provider?.name ?? humanizeModelLabel(model.providerID);
  const modelLabel = modelInfo?.name ?? humanizeModelLabel(model.modelID);

  return `${providerLabel} · ${modelLabel}`;
}

export function isTauriRuntime() {
  return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null;
}

export function isWindowsPlatform() {
  if (typeof navigator === "undefined") return false;

  const ua = typeof navigator.userAgent === "string" ? navigator.userAgent : "";
  const platform =
    typeof (navigator as any).userAgentData?.platform === "string"
      ? (navigator as any).userAgentData.platform
      : typeof navigator.platform === "string"
        ? navigator.platform
        : "";

  return /windows/i.test(platform) || /windows/i.test(ua);
}

export function readModePreference(): "host" | "client" | null {
  if (typeof window === "undefined") return null;

  try {
    const pref =
      window.localStorage.getItem("openwork.modePref") ??
      window.localStorage.getItem("openwork_mode_pref");

    if (pref === "host" || pref === "client") {
      // Migrate legacy key if needed.
      try {
        window.localStorage.setItem("openwork.modePref", pref);
      } catch {
        // ignore
      }
      return pref;
    }
  } catch {
    // ignore
  }

  return null;
}

export function writeModePreference(nextMode: "host" | "client") {
  if (typeof window === "undefined") return;

  try {
    window.localStorage.setItem("openwork.modePref", nextMode);
    // Keep legacy key for now.
    window.localStorage.setItem("openwork_mode_pref", nextMode);
  } catch {
    // ignore
  }
}

export function clearModePreference() {
  if (typeof window === "undefined") return;

  try {
    window.localStorage.removeItem("openwork.modePref");
    window.localStorage.removeItem("openwork_mode_pref");
  } catch {
    // ignore
  }
}

export function safeStringify(value: unknown) {
  const seen = new WeakSet<object>();

  try {
    return JSON.stringify(
      value,
      (key, val) => {
        if (val && typeof val === "object") {
          if (seen.has(val as object)) {
            return "<circular>";
          }
          seen.add(val as object);
        }

        const lowerKey = key.toLowerCase();
        if (
          lowerKey === "reasoningencryptedcontent" ||
          lowerKey.includes("api_key") ||
          lowerKey.includes("apikey") ||
          lowerKey.includes("access_token") ||
          lowerKey.includes("refresh_token") ||
          lowerKey.includes("token") ||
          lowerKey.includes("authorization") ||
          lowerKey.includes("cookie") ||
          lowerKey.includes("secret")
        ) {
          return "[redacted]";
        }

        return val;
      },
      2,
    );
  } catch {
    return "<unserializable>";
  }
}

export function formatBytes(bytes: number) {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB"] as const;
  const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
  const value = bytes / Math.pow(1024, idx);
  const rounded = idx === 0 ? Math.round(value) : Math.round(value * 10) / 10;
  return `${rounded} ${units[idx]}`;
}

export function normalizeDirectoryPath(input?: string | null) {
  const trimmed = (input ?? "").trim();
  if (!trimmed) return "";
  const unified = trimmed.replace(/\\/g, "/");
  const withoutTrailing = unified.replace(/\/+$/, "");
  const normalized = withoutTrailing || "/";
  return isWindowsPlatform() ? normalized.toLowerCase() : normalized;
}

export function normalizeEvent(raw: unknown): OpencodeEvent | null {
  if (!raw || typeof raw !== "object") {
    return null;
  }

  const record = raw as Record<string, unknown>;

  if (typeof record.type === "string") {
    return {
      type: record.type,
      properties: record.properties,
    };
  }

  if (record.payload && typeof record.payload === "object") {
    const payload = record.payload as Record<string, unknown>;
    if (typeof payload.type === "string") {
      return {
        type: payload.type,
        properties: payload.properties,
      };
    }
  }

  return null;
}

export function formatRelativeTime(timestampMs: number) {
  const delta = Date.now() - timestampMs;

  if (delta < 0) {
    return "just now";
  }

  if (delta < 60_000) {
    return `${Math.max(1, Math.round(delta / 1000))}s ago`;
  }

  if (delta < 60 * 60_000) {
    return `${Math.max(1, Math.round(delta / 60_000))}m ago`;
  }

  if (delta < 24 * 60 * 60_000) {
    return `${Math.max(1, Math.round(delta / (60 * 60_000)))}h ago`;
  }

  return new Date(timestampMs).toLocaleDateString();
}

export function commandPathFromWorkspaceRoot(workspaceRoot: string, commandName: string) {
  const root = workspaceRoot.trim().replace(/\/+$/, "");
  const name = commandName.trim().replace(/^\/+/, "");
  if (!root || !name) return null;
  return `${root}/.opencode/commands/${name}.md`;
}

export function safeParseJson<T>(raw: string): T | null {
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

export function addOpencodeCacheHint(message: string) {
  const lower = message.toLowerCase();
  const cacheSignals = [
    ".cache/opencode",
    "library/caches/opencode",
    "appdata/local/opencode",
    "fetch_jwks.js",
    "opencode cache",
  ];

  if (cacheSignals.some((signal) => lower.includes(signal)) && lower.includes("enoent")) {
    return `${message}\n\nOpenCode cache looks corrupted. Use Repair cache in Settings to rebuild it.`;
  }

  return message;
}

export function parseTemplateFrontmatter(raw: string) {
  const trimmed = raw.trimStart();
  if (!trimmed.startsWith("---")) return null;
  const endIndex = trimmed.indexOf("\n---", 3);
  if (endIndex === -1) return null;
  const header = trimmed.slice(3, endIndex).trim();
  const body = trimmed.slice(endIndex + 4).replace(/^\r?\n/, "");
  const data: Record<string, string> = {};

  const unescapeValue = (value: string) => {
    if (value.startsWith("\"") && value.endsWith("\"")) {
      const inner = value.slice(1, -1);
      return inner.replace(/\\(\\|\"|n|r|t)/g, (_match, code) => {
        switch (code) {
          case "n":
            return "\n";
          case "r":
            return "\r";
          case "t":
            return "\t";
          case "\\":
            return "\\";
          case "\"":
            return "\"";
          default:
            return code;
        }
      });
    }

    if (value.startsWith("'") && value.endsWith("'")) {
      return value.slice(1, -1).replace(/''/g, "'");
    }

    return value;
  };

  for (const line of header.split(/\r?\n/)) {
    const entry = line.trim();
    if (!entry) continue;
    const colonIndex = entry.indexOf(":");
    if (colonIndex === -1) continue;
    const key = entry.slice(0, colonIndex).trim();
    let value = entry.slice(colonIndex + 1).trim();
    if (!key) continue;
    value = unescapeValue(value);
    data[key] = value;
  }

  return { data, body };
}

export function upsertSession(list: Session[], next: Session) {
  const idx = list.findIndex((s) => s.id === next.id);
  if (idx === -1) return [...list, next];

  const copy = list.slice();
  copy[idx] = next;
  return copy;
}

export function upsertMessage(list: MessageWithParts[], nextInfo: MessageInfo) {
  const idx = list.findIndex((m) => m.info.id === nextInfo.id);
  if (idx === -1) {
    return list.concat({ info: nextInfo, parts: [] });
  }

  const copy = list.slice();
  copy[idx] = { ...copy[idx], info: nextInfo };
  return copy;
}

export function upsertPart(list: MessageWithParts[], nextPart: Part) {
  const msgIdx = list.findIndex((m) => m.info.id === nextPart.messageID);
  if (msgIdx === -1) {
    // avoids missing streaming events before message.updated
    const placeholder: PlaceholderAssistantMessage = {
      id: nextPart.messageID,
      sessionID: nextPart.sessionID,
      role: "assistant",
      time: { created: Date.now() },
      parentID: "",
      modelID: "",
      providerID: "",
      mode: "",
      agent: "",
      path: { cwd: "", root: "" },
      cost: 0,
      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
    };

    return list.concat({ info: placeholder, parts: [nextPart] });
  }

  const copy = list.slice();
  const msg = copy[msgIdx];
  const parts = msg.parts.slice();
  const partIdx = parts.findIndex((p) => p.id === nextPart.id);

  if (partIdx === -1) {
    parts.push(nextPart);
  } else {
    parts[partIdx] = nextPart;
  }

  copy[msgIdx] = { ...msg, parts };
  return copy;
}

export function removePart(list: MessageWithParts[], messageID: string, partID: string) {
  const msgIdx = list.findIndex((m) => m.info.id === messageID);
  if (msgIdx === -1) return list;

  const copy = list.slice();
  const msg = copy[msgIdx];
  copy[msgIdx] = { ...msg, parts: msg.parts.filter((p) => p.id !== partID) };
  return copy;
}

export function normalizeSessionStatus(status: unknown) {
  if (!status || typeof status !== "object") return "idle";
  const record = status as Record<string, unknown>;
  if (record.type === "busy") return "running";
  if (record.type === "retry") return "retry";
  if (record.type === "idle") return "idle";
  return "idle";
}

export function modelFromUserMessage(info: MessageInfo): ModelRef | null {
  if (!info || typeof info !== "object") return null;
  if ((info as any).role !== "user") return null;

  const model = (info as any).model as unknown;
  if (!model || typeof model !== "object") return null;

  const providerID = (model as any).providerID;
  const modelID = (model as any).modelID;

  if (typeof providerID !== "string" || typeof modelID !== "string") return null;
  return { providerID, modelID };
}

export function lastUserModelFromMessages(list: MessageWithParts[]): ModelRef | null {
  for (let i = list.length - 1; i >= 0; i -= 1) {
    const model = modelFromUserMessage(list[i]?.info);
    if (model) return model;
  }

  return null;
}

export function isStepPart(part: Part) {
  return part.type === "reasoning" || part.type === "tool" || part.type === "step-start" || part.type === "step-finish";
}

export function groupMessageParts(parts: Part[], messageId: string): MessageGroup[] {
  const groups: MessageGroup[] = [];
  const steps: Part[] = [];
  let textBuffer = "";

  const flushText = () => {
    if (!textBuffer) return;
    groups.push({ kind: "text", part: { type: "text", text: textBuffer } as Part });
    textBuffer = "";
  };

  parts.forEach((part) => {
    if (part.type === "text") {
      textBuffer += (part as { text?: string }).text ?? "";
      return;
    }

    if (part.type === "agent") {
      const name = (part as { name?: string }).name ?? "";
      textBuffer += name ? `@${name}` : "@agent";
      return;
    }

    if (part.type === "file") {
      const record = part as { label?: string; path?: string; filename?: string; url?: string };
      const url = record.url;
      if (typeof url === "string" && !url.startsWith("file://")) {
        flushText();
        return;
      }
      const label = record.label ?? record.path ?? record.filename ?? "";
      textBuffer += label ? `@${label}` : "@file";
      return;
    }

    flushText();
    steps.push(part);
  });

  flushText();

  if (steps.length) {
    groups.push({ kind: "steps", id: `steps-${messageId}`, parts: steps });
  }

  return groups;
}

export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: string } {
  if (part.type === "tool") {
    const record = part as any;
    const toolName = record.tool ? String(record.tool) : "Tool";
    const state = record.state ?? {};
    const title = state.title ? String(state.title) : toolName;
    const output = typeof state.output === "string" && state.output.trim() ? state.output.trim() : null;
    
    // Detect skill trigger
    if (toolName === "skill") {
      const skillName = state.metadata?.name || title.replace(/^Loaded skill:\s*/i, "");
      if (output) {
        const short = output.length > 160 ? `${output.slice(0, 160)}…` : output;
        return { title, isSkill: true, skillName, detail: short };
      }
      return { title, isSkill: true, skillName };
    }
    
    if (output) {
      const short = output.length > 160 ? `${output.slice(0, 160)}…` : output;
      return { title, detail: short };
    }
    return { title };
  }

  if (part.type === "reasoning") {
    const record = part as any;
    const text = typeof record.text === "string" ? record.text.trim() : "";
    if (!text) return { title: "Planning" };
    const short = text.length > 120 ? `${text.slice(0, 120)}…` : text;
    return { title: "Thinking", detail: short };
  }

  if (part.type === "step-start" || part.type === "step-finish") {
    const reason = (part as any).reason;
    return {
      title: part.type === "step-start" ? "Step started" : "Step finished",
      detail: reason ? String(reason) : undefined,
    };
  }

  return { title: "Step" };
}

export function deriveArtifacts(list: MessageWithParts[]): ArtifactItem[] {
  const results = new Map<string, ArtifactItem>();

  list.forEach((message) => {
    const messageId = String((message.info as any)?.id ?? "");

    message.parts.forEach((part) => {
      if (part.type !== "tool") return;
      const record = part as any;
      const state = record.state ?? {};
      const matches = new Set<string>();

      const explicit = [
        state.path,
        state.file,
        ...(Array.isArray(state.files) ? state.files : []),
      ];

      explicit.forEach((f) => {
        if (typeof f === "string") {
          const trimmed = f.trim();
          if (
            trimmed.length > 0 &&
            trimmed.length <= 500 &&
            trimmed.includes(".") &&
            !/^\.{2,}$/.test(trimmed)
          ) {
            matches.add(trimmed);
          }
        }
      });

      const text = [state.title, state.output]
        .filter((v): v is string => typeof v === "string")
        .join(" ");

      if (text) {
        const pathPattern =
          /(?:^|[\s"'`([{])((?:[a-zA-Z]:[/\\]|\.{1,2}[/\\]|~[/\\]|[/\\])[\w./\\\-]*\.[a-z][a-z0-9]{0,9}|[\w.\-]+[/\\][\w./\\\-]*\.[a-z][a-z0-9]{0,9})/gi;

        Array.from(text.matchAll(pathPattern))
          .map((m) => m[1])
          .filter((f) => f && f.length <= 500)
          .forEach((f) => matches.add(f));
      }

      if (matches.size === 0) return;

      matches.forEach((match) => {
        const normalizedPath = match.trim().replace(/[\\/]+/g, "/");
        if (!normalizedPath) return;

        const key = normalizedPath.toLowerCase();
        const name = normalizedPath.split("/").pop() ?? normalizedPath;
        const id = `artifact-${encodeURIComponent(normalizedPath)}`;

        // Delete and re-add to move to end (most recent)
        if (results.has(key)) results.delete(key);
        results.set(key, {
          id,
          name,
          path: normalizedPath,
          kind: "file" as const,
          size: state.size ? String(state.size) : undefined,
          messageId: messageId || undefined,
        });
      });
    });
  });

  return Array.from(results.values());
}

export function deriveWorkingFiles(items: ArtifactItem[]): string[] {
  const results: string[] = [];
  const seen = new Set<string>();

  for (const item of items) {
    const rawKey = item.path ?? item.name;
    const normalized = rawKey.trim().replace(/[\\/]+/g, "/").toLowerCase();
    if (!normalized || seen.has(normalized)) continue;
    seen.add(normalized);
    results.push(item.name);
    if (results.length >= 5) break;
  }

  return results;
}



================================================
FILE: packages/app/src/app/utils/keybinds.ts
================================================
const MODIFIER_ORDER = ["cmd", "ctrl", "alt", "shift"] as const;

type ModifierKey = (typeof MODIFIER_ORDER)[number];

type ParsedKeybind = {
  key: string;
  modifiers: Record<ModifierKey, boolean>;
};

const normalizeKey = (value: string) => {
  const trimmed = value.trim();
  if (!trimmed) return "";
  const lower = trimmed.toLowerCase();
  const normalized =
    lower === " "
      ? "space"
      : lower === "escape"
        ? "esc"
        : lower === "arrowup"
          ? "up"
          : lower === "arrowdown"
            ? "down"
            : lower === "arrowleft"
              ? "left"
              : lower === "arrowright"
                ? "right"
                : lower;
  return normalized;
};

const normalizeModifier = (value: string): ModifierKey | null => {
  const cleaned = value.trim().toLowerCase();
  if (cleaned === "cmd" || cleaned === "meta" || cleaned === "command" || cleaned === "⌘") return "cmd";
  if (cleaned === "ctrl" || cleaned === "control" || cleaned === "^") return "ctrl";
  if (cleaned === "alt" || cleaned === "option" || cleaned === "opt" || cleaned === "⌥") return "alt";
  if (cleaned === "shift" || cleaned === "⇧") return "shift";
  return null;
};

const emptyModifiers = (): Record<ModifierKey, boolean> => ({
  cmd: false,
  ctrl: false,
  alt: false,
  shift: false,
});

const parseKeybind = (value: string): ParsedKeybind | null => {
  const parts = value
    .split("+")
    .map((part) => part.trim())
    .filter(Boolean);
  if (!parts.length) return null;
  const modifiers = emptyModifiers();
  let key = "";
  for (const part of parts) {
    const modifierKey = normalizeModifier(part);
    if (modifierKey) {
      modifiers[modifierKey] = true;
    } else {
      key = normalizeKey(part);
    }
  }
  if (!key) return null;
  return { key, modifiers };
};

const formatKeybind = (parsed: ParsedKeybind) => {
  const parts: string[] = MODIFIER_ORDER.filter((modifier) => parsed.modifiers[modifier]);
  parts.push(parsed.key);
  return parts.join("+");
};

export const normalizeKeybind = (value: string) => {
  const parsed = parseKeybind(value);
  if (!parsed) return null;
  return formatKeybind(parsed);
};

export const keybindFromEvent = (event: KeyboardEvent) => {
  const key = normalizeKey(event.key);
  if (!key || key === "meta" || key === "control" || key === "shift" || key === "alt") return null;
  const parsed: ParsedKeybind = {
    key,
    modifiers: {
      cmd: event.metaKey,
      ctrl: event.ctrlKey,
      alt: event.altKey,
      shift: event.shiftKey,
    },
  };
  return formatKeybind(parsed);
};

export const matchKeybind = (event: KeyboardEvent, keybind: string) => {
  const parsed = parseKeybind(keybind);
  if (!parsed) return false;
  const key = normalizeKey(event.key);
  return (
    key === parsed.key &&
    event.metaKey === parsed.modifiers.cmd &&
    event.ctrlKey === parsed.modifiers.ctrl &&
    event.altKey === parsed.modifiers.alt &&
    event.shiftKey === parsed.modifiers.shift
  );
};

export const formatKeybindLabel = (keybind: string) => {
  const parsed = parseKeybind(keybind);
  if (!parsed) return keybind;
  const parts: string[] = MODIFIER_ORDER.filter((modifier) => parsed.modifiers[modifier]).map((modifier) => {
    switch (modifier) {
      case "cmd":
        return "Cmd";
      case "ctrl":
        return "Ctrl";
      case "alt":
        return "Alt";
      case "shift":
        return "Shift";
      default:
        return modifier;
    }
  });
  const keyLabel = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.toUpperCase();
  parts.push(keyLabel);
  return parts.join(" + ");
};

export const isEditableTarget = (target: EventTarget | null) => {
  if (!(target instanceof HTMLElement)) return false;
  if (target.isContentEditable) return true;
  const tag = target.tagName.toLowerCase();
  if (tag === "input" || tag === "textarea" || tag === "select") return true;
  return Boolean(target.closest("[contenteditable='true']"));
};



================================================
FILE: packages/app/src/app/utils/persist.ts
================================================
import { makePersisted } from "@solid-primitives/storage";
import { createResource, type Accessor } from "solid-js";
import type { SetStoreFunction, Store } from "solid-js/store";

import { usePlatform, type AsyncStorage, type SyncStorage } from "../context/platform";

type InitType = Promise<string> | string | null;
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>];

type PersistTarget = {
  storage?: string;
  key: string;
  legacy?: string[];
  migrate?: (value: unknown) => unknown;
};

const LEGACY_STORAGE = "default.dat";
const GLOBAL_STORAGE = "openwork.global.dat";

function snapshot(value: unknown) {
  return JSON.parse(JSON.stringify(value)) as unknown;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function merge(defaults: unknown, value: unknown): unknown {
  if (value === undefined) return defaults;
  if (value === null) return value;

  if (Array.isArray(defaults)) {
    if (Array.isArray(value)) return value;
    return defaults;
  }

  if (isRecord(defaults)) {
    if (!isRecord(value)) return defaults;

    const result: Record<string, unknown> = { ...defaults };
    for (const key of Object.keys(value)) {
      if (key in defaults) {
        result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key]);
      } else {
        result[key] = (value as Record<string, unknown>)[key];
      }
    }
    return result;
  }

  return value;
}

function parse(value: string) {
  try {
    return JSON.parse(value) as unknown;
  } catch {
    return undefined;
  }
}

function checksum(input: string) {
  let hash = 0;
  for (let i = 0; i < input.length; i += 1) {
    hash = (hash << 5) - hash + input.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash).toString(36);
}

function workspaceStorage(dir: string) {
  const head = dir.slice(0, 12) || "workspace";
  const sum = checksum(dir);
  return `openwork.workspace.${head}.${sum}.dat`;
}

function localStorageWithPrefix(prefix: string): SyncStorage {
  const base = `${prefix}:`;
  return {
    getItem: (key) => localStorage.getItem(base + key),
    setItem: (key, value) => localStorage.setItem(base + key, value),
    removeItem: (key) => localStorage.removeItem(base + key),
  };
}

export const Persist = {
  global(key: string, legacy?: string[]): PersistTarget {
    return { storage: GLOBAL_STORAGE, key, legacy };
  },
  workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
    return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy };
  },
  session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
    return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy };
  },
  scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
    if (session) return Persist.session(dir, session, key, legacy);
    return Persist.workspace(dir, key, legacy);
  },
};

export function removePersisted(target: { storage?: string; key: string }) {
  const platform = usePlatform();
  const isDesktop = platform.platform === "desktop" && !!platform.storage;

  if (isDesktop) {
    return platform.storage?.(target.storage)?.removeItem(target.key);
  }

  if (!target.storage) {
    localStorage.removeItem(target.key);
    return;
  }

  localStorageWithPrefix(target.storage).removeItem(target.key);
}

export function persisted<T>(
  target: string | PersistTarget,
  store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
  const platform = usePlatform();
  const config: PersistTarget = typeof target === "string" ? { key: target } : target;

  const defaults = snapshot(store[0]);
  const legacy = config.legacy ?? [];

  const isDesktop = platform.platform === "desktop" && !!platform.storage;

  const currentStorage = (() => {
    if (isDesktop) return platform.storage?.(config.storage);
    if (!config.storage) return localStorage;
    return localStorageWithPrefix(config.storage);
  })();

  const legacyStorage = (() => {
    if (!isDesktop) return localStorage;
    if (!config.storage) return platform.storage?.();
    return platform.storage?.(LEGACY_STORAGE);
  })();

  const storage = (() => {
    if (!isDesktop) {
      const current = currentStorage as SyncStorage;
      const legacyStore = legacyStorage as SyncStorage;

      const api: SyncStorage = {
        getItem: (key) => {
          const raw = current.getItem(key);
          if (raw !== null) {
            const parsed = parse(raw);
            if (parsed === undefined) return raw;

            const migrated = config.migrate ? config.migrate(parsed) : parsed;
            const merged = merge(defaults, migrated);
            const next = JSON.stringify(merged);
            if (raw !== next) current.setItem(key, next);
            return next;
          }

          for (const legacyKey of legacy) {
            const legacyRaw = legacyStore.getItem(legacyKey);
            if (legacyRaw === null) continue;

            current.setItem(key, legacyRaw);
            legacyStore.removeItem(legacyKey);

            const parsed = parse(legacyRaw);
            if (parsed === undefined) return legacyRaw;

            const migrated = config.migrate ? config.migrate(parsed) : parsed;
            const merged = merge(defaults, migrated);
            const next = JSON.stringify(merged);
            if (legacyRaw !== next) current.setItem(key, next);
            return next;
          }

          return null;
        },
        setItem: (key, value) => {
          current.setItem(key, value);
        },
        removeItem: (key) => {
          current.removeItem(key);
        },
      };

      return api;
    }

    const current = currentStorage as AsyncStorage;
    const legacyStore = legacyStorage as AsyncStorage | undefined;

    const api: AsyncStorage = {
      getItem: async (key) => {
        const raw = await current.getItem(key);
        if (raw !== null) {
          const parsed = parse(raw);
          if (parsed === undefined) return raw;

          const migrated = config.migrate ? config.migrate(parsed) : parsed;
          const merged = merge(defaults, migrated);
          const next = JSON.stringify(merged);
          if (raw !== next) await current.setItem(key, next);
          return next;
        }

        if (!legacyStore) return null;

        for (const legacyKey of legacy) {
          const legacyRaw = await legacyStore.getItem(legacyKey);
          if (legacyRaw === null) continue;

          await current.setItem(key, legacyRaw);
          await legacyStore.removeItem(legacyKey);

          const parsed = parse(legacyRaw);
          if (parsed === undefined) return legacyRaw;

          const migrated = config.migrate ? config.migrate(parsed) : parsed;
          const merged = merge(defaults, migrated);
          const next = JSON.stringify(merged);
          if (legacyRaw !== next) await current.setItem(key, next);
          return next;
        }

        return null;
      },
      setItem: async (key, value) => {
        await current.setItem(key, value);
      },
      removeItem: async (key) => {
        await current.removeItem(key);
      },
    };

    return api;
  })();

  const [state, setState, init] = makePersisted(store, { name: config.key, storage });

  const isAsync = init instanceof Promise;
  const [ready] = createResource(
    () => init,
    async (initValue) => {
      if (initValue instanceof Promise) await initValue;
      return true;
    },
    { initialValue: !isAsync },
  );

  return [state, setState, init, () => ready() === true];
}



================================================
FILE: packages/app/src/app/utils/plugins.ts
================================================
import { parse } from "jsonc-parser";

import type { OpencodeConfigFile } from "../lib/tauri";

type PluginListValue = string | string[] | null | undefined;

type PluginConfig = {
  content: string | null;
} | null;

export function normalizePluginList(value: PluginListValue) {
  if (!value) return [] as string[];
  if (Array.isArray(value)) {
    return value
      .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
      .filter((entry) => entry.length > 0);
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    return trimmed ? [trimmed] : [];
  }
  return [] as string[];
}

export function stripPluginVersion(spec: string) {
  const trimmed = spec.trim();
  if (!trimmed) return "";

  const looksLikeVersion = (suffix: string) =>
    /^(latest|next|beta|alpha|canary|rc|stable|\d)/i.test(suffix);

  if (trimmed.startsWith("@")) {
    const slashIndex = trimmed.indexOf("/");
    if (slashIndex === -1) return trimmed;

    const atIndex = trimmed.indexOf("@", slashIndex + 1);
    if (atIndex === -1) return trimmed;

    const suffix = trimmed.slice(atIndex + 1);
    return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed;
  }

  const atIndex = trimmed.indexOf("@");
  if (atIndex === -1) return trimmed;

  const suffix = trimmed.slice(atIndex + 1);
  return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed;
}

export function isPluginInstalled(pluginList: string[], pluginName: string, aliases: string[] = []) {
  const normalized = pluginList.flatMap((entry) => {
    const raw = entry.toLowerCase();
    const stripped = stripPluginVersion(entry).toLowerCase();
    return stripped && stripped !== raw ? [raw, stripped] : [raw];
  });

  const list = new Set(normalized);
  return [pluginName, ...aliases].some((entry) => list.has(entry.toLowerCase()));
}

export function loadPluginsFromConfig(
  config: PluginConfig,
  onList: (next: string[]) => void,
  onError: (message: string) => void,
) {
  if (!config?.content) {
    onList([]);
    return;
  }

  try {
    const parsed = parse(config.content) as Record<string, unknown> | undefined;
    const next = normalizePluginList(parsed?.plugin as PluginListValue);
    onList(next);
  } catch (e) {
    onList([]);
    onError(e instanceof Error ? e.message : "Failed to parse opencode.json");
  }
}

export function parsePluginsFromConfig(config: OpencodeConfigFile | null) {
  if (!config?.content) return [] as string[];
  return parsePluginListFromContent(config.content);
}

export function parsePluginListFromContent(content: string) {
  try {
    const parsed = parse(content) as Record<string, unknown> | undefined;
    return normalizePluginList(parsed?.plugin as PluginListValue);
  } catch {
    return [] as string[];
  }
}



================================================
FILE: packages/app/src/app/utils/providers.ts
================================================
import type { Provider as ConfigProvider, ProviderListResponse } from "@opencode-ai/sdk/v2/client";

type ProviderListItem = ProviderListResponse["all"][number];
type ProviderListModel = ProviderListItem["models"][string];

const buildModalities = (caps?: ConfigProvider["models"][string]["capabilities"]) => {
  if (!caps) return undefined;

  const input = Object.entries(caps.input)
    .filter(([, enabled]) => enabled)
    .map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");
  const output = Object.entries(caps.output)
    .filter(([, enabled]) => enabled)
    .map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");

  if (!input.length && !output.length) return undefined;
  return { input, output };
};

const mapModel = (model: ConfigProvider["models"][string]): ProviderListModel => {
  const interleaved = model.capabilities?.interleaved;
  const modalities = buildModalities(model.capabilities);
  const status = model.status === "alpha" || model.status === "beta" || model.status === "deprecated"
    ? model.status
    : undefined;

  return {
    id: model.id,
    name: model.name ?? model.id,
    family: model.family,
    release_date: model.release_date ?? "",
    attachment: model.capabilities?.attachment ?? false,
    reasoning: model.capabilities?.reasoning ?? false,
    temperature: model.capabilities?.temperature ?? false,
    tool_call: model.capabilities?.toolcall ?? false,
    interleaved: interleaved === false ? undefined : interleaved,
    cost: model.cost
      ? {
          input: model.cost.input,
          output: model.cost.output,
          cache_read: model.cost.cache.read,
          cache_write: model.cost.cache.write,
          context_over_200k: model.cost.experimentalOver200K
            ? {
                input: model.cost.experimentalOver200K.input,
                output: model.cost.experimentalOver200K.output,
                cache_read: model.cost.experimentalOver200K.cache.read,
                cache_write: model.cost.experimentalOver200K.cache.write,
              }
            : undefined,
        }
      : undefined,
    limit: model.limit,
    modalities,
    experimental: status === "alpha" ? true : undefined,
    status,
    options: model.options ?? {},
    headers: model.headers ?? undefined,
    provider: model.api?.npm ? { npm: model.api.npm } : undefined,
    variants: model.variants,
  };
};

export const mapConfigProvidersToList = (providers: ConfigProvider[]): ProviderListResponse["all"] =>
  providers.map((provider) => {
    const models = Object.fromEntries(
      Object.entries(provider.models ?? {}).map(([key, model]) => [key, mapModel(model)]),
    );

    return {
      id: provider.id,
      name: provider.name ?? provider.id,
      env: provider.env ?? [],
      models,
    };
  });



================================================
FILE: packages/app/src/i18n/index.ts
================================================
import { createSignal, createRoot } from "solid-js";
import en from "./locales/en";
import zh from "./locales/zh";
import { LANGUAGE_PREF_KEY } from "../app/constants";

/**
 * Supported languages - only en and zh for initial PR
 */
export type Language = "en" | "zh";
export type Locale = Language;

/**
 * All supported languages - single source of truth
 */
export const LANGUAGES: Language[] = ["en", "zh"];

/**
 * Language options for UI - single source of truth
 */
export const LANGUAGE_OPTIONS = [
  { value: "en" as Language, label: "English", nativeName: "English" },
  { value: "zh" as Language, label: "简体中文", nativeName: "简体中文" },
] as const;

/**
 * Translation maps
 */
const TRANSLATIONS: Record<Language, Record<string, string>> = {
  en,
  zh,
};

/**
 * Type guard to validate if a value is a Language
 * Replaces long chains like: value === "en" || value === "zh"
 */
export const isLanguage = (value: unknown): value is Language => {
  return typeof value === "string" && LANGUAGES.includes(value as Language);
};

/**
 * Create root-level locale signal with persistence
 */
const [locale, setLocaleSignal] = createRoot(() => createSignal<Language>("en"));

/**
 * Get current locale
 */
export const currentLocale = (): Language => locale();

/**
 * Set locale and persist to localStorage
 */
export const setLocale = (newLocale: Language) => {
  if (!isLanguage(newLocale)) {
    console.warn(`Invalid locale: ${newLocale}, falling back to "en"`);
    newLocale = "en";
  }

  setLocaleSignal(newLocale);

  // Persist to localStorage
  if (typeof window !== "undefined") {
    try {
      window.localStorage.setItem(LANGUAGE_PREF_KEY, newLocale);
    } catch (e) {
      console.warn("Failed to persist language preference:", e);
    }
  }
};

/**
 * Translation function with fallback behavior
 * Fallback chain: target language → English → key itself
 *
 * @param key - Translation key
 * @param localeOverride - Optional locale override (defaults to current locale)
 * @returns Translated string or fallback
 */
export const t = (key: string, localeOverride?: Language): string => {
  const loc = localeOverride ?? locale();

  // Try target language first
  if (TRANSLATIONS[loc]?.[key]) {
    return TRANSLATIONS[loc][key];
  }

  // Fallback to English
  if (loc !== "en" && TRANSLATIONS.en?.[key]) {
    return TRANSLATIONS.en[key];
  }

  // Final fallback to key itself (prevents raw keys from showing in UI)
  return key;
};

/**
 * Initialize locale from localStorage
 * Call this during app initialization
 */
export const initLocale = (): Language => {
  if (typeof window === "undefined") {
    return "en";
  }

  try {
    const stored = window.localStorage.getItem(LANGUAGE_PREF_KEY);
    if (isLanguage(stored)) {
      setLocaleSignal(stored);
      return stored;
    }
  } catch (e) {
    console.warn("Failed to read language preference:", e);
  }

  return "en";
};



================================================
FILE: packages/app/src/i18n/locales/en.ts
================================================
/**
 * English translations
 * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated
 */

export default {
  // ==================== Dashboard ====================
  "dashboard.title": "Dashboard",
  "dashboard.sessions": "Sessions",
  "dashboard.commands": "Commands",
  "dashboard.skills": "Skills",
  "dashboard.plugins": "Plugins",
  "dashboard.mcps": "MCPs",
  "dashboard.settings": "Settings",
  "dashboard.home": "Home",
  "dashboard.runs": "Runs",
  "dashboard.find_workspace": "Find workspace...",
  "dashboard.workspaces": "Workspaces",
  "dashboard.new_workspace": "New Workspace...",
  "dashboard.new_remote_workspace": "Add Remote Workspace...",
  "dashboard.forget_workspace": "Forget workspace",
  "dashboard.remote": "Remote",
  "dashboard.connection": "Connection",
  "dashboard.local_engine": "Local Engine",
  "dashboard.client_mode": "Client Mode",
  "dashboard.connected": "Connected",
  "dashboard.not_connected": "Not connected",
  "dashboard.stop_disconnect": "Stop & Disconnect",
  "dashboard.disconnect": "Disconnect",
  "dashboard.new_task": "New Task",
  "dashboard.new": "New",
  "dashboard.busy": "Busy",
  "dashboard.hero_title": "What should we do today?",
  "dashboard.hero_description": "Describe an outcome. OpenWork will run it and keep an audit trail.",
  "dashboard.quick_start_commands": "Quick Start Commands",
  "dashboard.view_all": "View all",
  "dashboard.no_commands": "No commands yet. Starter commands will appear here.",
  "dashboard.run_command": "Run a saved command",
  "dashboard.recent_sessions": "Recent Sessions",
  "dashboard.this_workspace": "this workspace",
  "dashboard.no_sessions": "No sessions yet.",
  "dashboard.idle": "Idle",
  "dashboard.running": "Running",
  "dashboard.completed": "Completed",
  "dashboard.failed": "Failed",
  "dashboard.repairing_cache": "Repairing cache",
  "dashboard.repair_cache": "Repair cache",
  "dashboard.retry": "Retry",
  "dashboard.alpha": "Alpha",
  "dashboard.create_workspace_title": "Create Workspace",
  "dashboard.create_workspace_subtitle": "Initialize a new folder-based workspace.",
  "dashboard.create_workspace_confirm": "Create Workspace",
  "dashboard.create_remote_workspace_title": "Add Remote Workspace",
  "dashboard.create_remote_workspace_subtitle": "Save an OpenWork host or OpenCode engine as a workspace.",
  "dashboard.create_remote_workspace_confirm": "Add Workspace",
  "dashboard.remote_workspace_title": "Remote workspace",
  "dashboard.remote_workspace_hint": "Track an OpenWork host or OpenCode engine and reconnect anytime.",
  "dashboard.remote_base_url_label": "OpenCode URL",
  "dashboard.remote_base_url_placeholder": "http://127.0.0.1:4096",
  "dashboard.remote_base_url_required": "Add a server URL to continue.",
  "dashboard.openwork_host_label": "OpenWork host URL",
  "dashboard.openwork_host_placeholder": "https://your-host.openwork.app",
  "dashboard.openwork_host_hint": "Use the URL shared by your OpenWork host.",
  "dashboard.openwork_host_token_label": "Client token",
  "dashboard.openwork_host_token_placeholder": "Paste your token",
  "dashboard.openwork_host_token_hint": "Optional. Paste the client token from the host to pair.",
  "dashboard.remote_mode_openwork_alpha": "OpenWork host (alpha)",
  "dashboard.remote_mode_direct": "OpenCode direct (recommended)",
  "dashboard.remote_connection_openwork": "OpenWork",
  "dashboard.remote_connection_direct": "Direct",
  "dashboard.remote_directory_label": "Workspace directory (optional)",
  "dashboard.remote_directory_placeholder": "/home/team/project",
  "dashboard.remote_directory_hint": "Leave blank to use the server default.",
  "dashboard.remote_display_name_label": "Display name (optional)",
  "dashboard.remote_display_name_placeholder": "Design team workspace",
  "dashboard.select_folder": "Select Folder",
  "dashboard.choose_folder": "Choose a folder",
  "dashboard.choose_folder_next": "You will choose a directory next.",
  "dashboard.change": "Change",
  "dashboard.opening": "Opening...",
  "dashboard.choose_preset": "Choose Preset",
  "dashboard.choose_folder_continue": "Choose a folder to continue.",
  "dashboard.starter_workspace": "Starter workspace",
  "dashboard.starter_workspace_desc": "Preconfigured to show you how to use plugins, commands, and skills.",
  "dashboard.empty_workspace": "Empty workspace",
  "dashboard.empty_workspace_desc": "Start with a blank folder and add what you need.",

  // ==================== Session ====================
  "session.no_selected": "No session selected",
  "session.back_to_dashboard": "Back to dashboard",
  "session.new_task": "New task",
  "session.recents": "Recents",
  "session.recents_notice": "These tasks run locally and aren't synced across devices.",
  "session.ready_to_work_title": "Ready to work",
  "session.ready_to_work_description": "Describe a task. I'll show progress and ask for permissions when needed.",
  "session.document_label": "Document",
  "session.standard_label": "Standard",
  "session.no_artifacts_fallback": "No artifacts yet.",
  "session.active_plugins_label": "Active plugins",
  "session.active_plugins_count": "{count}",
  "session.no_plugins_loaded": "No plugins loaded.",
  "session.selected_folders_label": "Selected folders",
  "session.selected_folders_count": "{count}",
  "session.working_files_label": "Working files",
  "session.none_yet_label": "None yet.",
  "session.permission_required_title": "Permission Required",
  "session.permission_required_description": "OpenCode is requesting permission to continue.",
  "session.permission_label_uppercase": "Permission",
  "session.scope_label_uppercase": "Scope",
  "session.details_label": "Details",
  "session.steps_notice_text": "Steps will show as the task unfolds.",
  "session.ready_to_work": "Ready to work",
  "session.ready_description": "Describe a task. I'll show progress and ask for permissions when needed.",
  "session.hide_steps": "Hide steps",
  "session.view_steps": "View steps",
  "session.open": "Open",
  "session.reveal": "Reveal",
  "session.opened_toast": "Opened in default app.",
  "session.revealed_toast": "Revealed in file manager.",
  "session.artifact_path_missing": "Artifact path missing.",
  "session.desktop_only": "Open is only available in the desktop app.",
  "session.open_failed": "Could not open artifact.",
  "session.model": "Model",
  "session.ready": "Ready",
  "session.connect_provider": "Connect a provider to customize this.",
  "session.running": "Running",
  "session.progress": "Progress",
  "session.steps_notice": "Steps will show as the task unfolds.",
  "session.artifacts": "Artifacts",
  "session.no_artifacts": "No artifacts yet.",
  "session.context": "Context",
  "session.active_plugins": "Active plugins",
  "session.no_plugins": "No plugins loaded.",
  "session.selected_folders": "Selected folders",
  "session.working_files": "Working files",
  "session.none_yet": "None yet.",
  "session.document": "Document",
  "session.standard": "Standard",
  "session.try_notion_prompt": "Try it now: set up my CRM in Notion",
  "session.insert_prompt": "Insert prompt",
  "session.placeholder": "Ask OpenWork...",
  "session.run": "Run",
  "session.permission_required": "Permission Required",
  "session.permission_description": "OpenCode is requesting permission to continue.",
  "session.permission_label": "Permission",
  "session.scope_label": "Scope",
  "session.details": "Details",
  "session.deny": "Deny",
  "session.once": "Once",
  "session.allow_for_session": "Allow for session",

  "session.tasks_local_hint": "These tasks run locally and aren't synced across devices.",
  "session.recents_label": "Recents",
  "session.model_standard": "Standard",
  "session.run_button_title": "Run",
  "session.rename_title": "Rename session",
  "session.rename_description": "Update the name for this session.",
  "session.rename_label": "Session name",
  "session.rename_placeholder": "Enter a new name",
  // ==================== Commands ====================
  "commands.new": "New",
  "commands.empty_state": "Save a prompt or command so you can run it again in one tap.",
  "commands.workspace": "This workspace",
  "commands.global": "Every workspace",
  "commands.other": "Other commands",
  "commands.run": "Run",
  "commands.modal_title": "Save a reusable flow",
  "commands.modal_description": "Store a prompt or command for quick reuse.",
  "commands.name_label": "Command name",
  "commands.name_placeholder": "e.g. daily-standup",
  "commands.name_hint": "This becomes /daily-standup in OpenCode.",
  "commands.description_label": "Description (optional)",
  "commands.description_placeholder": "What does this command do?",
  "commands.template_label": "Instructions",
  "commands.template_placeholder": "Write the instructions you want to reuse…",
  "commands.template_hint": "Use $ARGUMENTS to accept details.",
  "commands.details_required": "Details",
  "commands.default_description": "Run a saved command",
  "commands.command_label": "Command",
  "commands.details_label": "Details",
  "commands.details_placeholder": "Add optional details",
  "commands.details_hint": "These details are passed to the command.",
  "commands.run_modal_title": "Run command",
  "commands.run_modal_description": "Add optional details before running.",
  "commands.run_modal_run": "Run command",
  "commands.name_will_be": "Will be created as",
  "commands.override_title": "Replace existing command?",
  "commands.override_description": "A command with this name already exists.",
  "commands.override_warning": "This will replace the existing \"{name}\" command. This action cannot be undone.",
  "commands.override_confirm": "Replace",
  "commands.override_cancel": "Keep existing",

  // ==================== Skills ====================
  "skills.title": "Skills",
  "skills.subtitle": "Manage skills for this workspace.",
  "skills.refresh": "Refresh",
  "skills.add_title": "Add skills",
  "skills.add_description": "Install a starter command, import a skill, or open the folder.",
  "skills.install_from_openpackage": "Install from OpenPackage",
  "skills.host_mode_only": "Host mode only",
  "skills.install": "Install",
  "skills.installed_label": "Installed",
  "skills.install_hint": "Installs OpenPackage packages into the current workspace. Skills should land in `.opencode/skills`.",
  "skills.import_local": "Import local skill",
  "skills.import_local_hint": "Copy an existing skill folder into this workspace.",
  "skills.import": "Import",
  "skills.curated_packages": "Curated packages",
  "skills.view": "View",
  "skills.search_placeholder": "Search packages or lists (e.g. claude, registry, community)",
  "skills.no_matches": "No curated matches. Try a different search.",
  "skills.install_package": "Install",
  "skills.registry_notice": "Publishing to the OpenPackage registry (`opkg push`) requires authentication today. A registry search + curated list sync is planned.",
  "skills.installed": "Installed skills",
  "skills.no_skills": "No skills detected in `.opencode/skills` or `.claude/skills`.",
  "skills.desktop_required": "Skill management requires the desktop app.",
  "skills.host_only_error": "Skill management is only available in Host mode.",
  "skills.install_skill_creator": "Install skill creator",
  "skills.install_skill_creator_hint": "This skill allows you to create other skills from within the chat.",
  "skills.installing_skill_creator": "Installing skill creator...",
  "skills.skill_creator_installed": "Skill creator installed.",
  "skills.skill_creator_already_installed": "Skill creator is already installed.",
  "skills.install_failed": "Skill install failed.",
  "skills.reveal_folder": "Open skills folder",
  "skills.reveal_folder_hint": "Open the skill directory in Finder.",
  "skills.reveal_button": "Reveal in Finder",
  "skills.reveal_failed": "Failed to open skills folder.",
  "skills.uninstall": "Uninstall",
  "skills.uninstall_title": "Uninstall skill?",
  "skills.uninstall_warning": "This will permanently delete the `{name}` skill from your workspace.",
  "skills.uninstall_failed": "Failed to uninstall skill.",
  "skills.uninstalled": "Skill removed.",
  "skills.source_placeholder": "github:anthropics/claude-code",
  "skills.notion_crm_title": "Notion CRM Enrichment Skills",
  "skills.notion_crm_description": "Add enrichment workflows for contacts, pipelines, and follow-ups.",
  "skills.notion_crm_card_description": "Enrich Notion CRM data with ready-made skills.",
  "skills.connect_host_to_load": "Connect to a host to load skills.",
  "skills.pick_workspace_first": "Pick a workspace folder first.",
  "skills.no_skills_found": "No skills found yet.",
  "skills.installed_description": "Skills available in this workspace.",
  "skills.failed_to_load": "Failed to load skills",
  "skills.plugin_management_host_only": "Plugin management is only available in Host mode.",
  "skills.plugins_host_only": "Plugins are only available in Host mode.",
  "skills.pick_project_for_plugins": "Pick a project folder to manage project plugins.",
  "skills.pick_project_for_active": "Pick a project folder to load active plugins.",
  "skills.no_opencode_found": "No opencode.json found yet. Add a plugin to create one.",
  "skills.no_opencode_workspace": "No opencode.json in this workspace yet.",
  "skills.failed_parse_opencode": "Failed to parse opencode.json",
  "skills.failed_load_opencode": "Failed to load opencode.json",
  "skills.failed_load_active": "Failed to load active plugins.",
  "skills.enter_plugin_name": "Enter a plugin package name.",
  "skills.plugin_already_listed": "Plugin already listed in opencode.json.",
  "skills.failed_update_opencode": "Failed to update opencode.json",
  "skills.opackage_install_host_only": "OpenPackage installs are only available in Host mode.",
  "skills.pick_project_first": "Pick a project folder first.",
  "skills.enter_opackage_source": "Enter an OpenPackage source (e.g. github:anthropics/claude-code).",
  "skills.installing_opackage": "Installing OpenPackage...",
  "skills.install_complete": "Installed.",
  "skills.curated_list_notice": "This is a curated list, not an OpenPackage yet. Copy the link or watch the PRD for planned registry search integration.",
  "skills.import_host_only": "Skill import is only available in Host mode.",
  "skills.select_skill_folder": "Select skill folder",
  "skills.import_failed": "Import failed ({status})",
  "skills.imported": "Imported.",
  "skills.unknown_error": "Unknown error",

  // ==================== Plugins ====================
  "plugins.title": "OpenCode plugins",
  "plugins.description": "Manage `opencode.json` for your project or global OpenCode plugins.",
  "plugins.config_label": "Config",
  "plugins.config_not_loaded": "Not loaded yet",
  "plugins.suggested_label": "Suggested plugins",
  "plugins.no_plugins_yet": "No plugins configured yet.",
  "plugins.enabled_label": "Enabled",
  "plugins.open_label": "Open",
  "plugins.path_label": "Path",
  "plugins.scope_project": "Project",
  "plugins.scope_global": "Global",
  "plugins.refresh": "Refresh",
  "plugins.config": "Config",
  "plugins.not_loaded": "Not loaded yet",
  "plugins.suggested": "Suggested plugins",
  "plugins.hide_setup": "Hide setup",
  "plugins.setup": "Setup",
  "plugins.added": "Added",
  "plugins.add": "Add",
  "plugins.enabled": "Enabled",
  "plugins.no_plugins": "No plugins configured yet.",
  "plugins.add_label": "Add plugin",
  "plugins.placeholder": "opencode-wakatime",
  "plugins.add_hint": "Add npm package names, e.g. opencode-wakatime",

  // ==================== MCP (Model Context Protocol) ====================
  "mcp.title": "MCP (Alpha)",
  "mcp.description": "MCP servers let you connect services with your own credentials.",
  "mcp.alpha_banner_title": "MCP is in alpha while we harden OAuth with OpenCode.",
  "mcp.alpha_banner_help": "If you want to help, open a PR and include a short video showing the OAuth flow works end to end.",
  "mcp.mcps_title": "MCPs",
  "mcp.connect_mcp_hint": "Connect MCP servers to expand what OpenWork can do.",
  "mcp.reload_banner_title": "Finish setup",
  "mcp.reload_banner_description": "Reload to activate the new MCP connection. This is required setup, not an error.",
  "mcp.reload_banner_description_blocked": "A task is running in this workspace. Stop it, then reload to finish setup.",
  "mcp.reload_banner_blocked_hint": "Stop active runs to reload.",
  "mcp.quick_connect_title": "Quick connect",
  "mcp.oauth_only_label": "OAuth + local",
  "mcp.connected_status": "Connected",
  "mcp.no_env_vars": "No environment variables required.",
  "mcp.connected_title": "Connected",
  "mcp.from_opencode_json": "From opencode.json",
  "mcp.no_servers_yet": "No MCP servers configured yet.",
  "mcp.edit_config_title": "Edit MCP config",
  "mcp.edit_config_description": "MCP servers live in OpenCode's opencode.json.",
  "mcp.docs_link": "Docs",
  "mcp.scope_project": "Project",
  "mcp.scope_global": "Global",
  "mcp.config_label": "Config",
  "mcp.config_not_loaded": "Not loaded yet",
  "mcp.open_file_label": "Open file",
  "mcp.reveal_in_finder": "Reveal in Finder",
  "mcp.opening_label": "Opening",
  "mcp.file_not_found": "File not found",
  "mcp.config_load_failed": "Failed to load config path",
  "mcp.open_file": "Open file",
  "mcp.pick_workspace_error": "Pick a workspace folder to reveal the project opencode.json.",
  "mcp.reveal_config_failed": "Failed to reveal config",
  "mcp.alpha_warning": "MCP is in alpha while we harden OAuth with OpenCode.",
  "mcp.github_issue": "View issue #9510 on GitHub",
  "mcp.contribution_guide": "If you want to help, open a PR and include a short video showing the OAuth flow works end to end.",
  "mcp.hide_advanced": "Hide advanced settings",
  "mcp.show_advanced": "Show advanced settings",
  "mcp.mcps_label": "MCPs",
  "mcp.mcps_description": "Connect MCP servers to expand what OpenWork can do.",
  "mcp.configured": "configured",
  "mcp.updated": "Updated",
  "mcp.reload_required": "Reload required",
  "mcp.reload_description": "Changes need a quick reload to activate MCP tools.",
  "mcp.reload_engine": "Reload Engine",
  "mcp.quick_connect": "Quick connect",
  "mcp.oauth_only": "OAuth-only",
  "mcp.connecting": "Connecting",
  "mcp.connect": "Connect",
  "mcp.connected": "Connected",
  "mcp.connected_label": "Connected",
  "mcp.no_env_required": "No environment variables required.",
  "mcp.config_source": "From opencode.json",
  "mcp.no_servers": "No MCP servers configured yet.",
  "mcp.advanced": "Advanced",
  "mcp.advanced_description": "Manual setup for custom servers.",
  "mcp.hide": "Hide",
  "mcp.show": "Show",
  "mcp.server_name": "Server name",
  "mcp.server_name_placeholder": "sentry",
  "mcp.server_url": "Server URL",
  "mcp.server_url_placeholder": "https://mcp.sentry.dev/mcp",
  "mcp.oauth": "OAuth",
  "mcp.api_key": "API key",
  "mcp.enabled": "Enabled",
  "mcp.disabled": "Disabled",
  "mcp.add_mcp": "Add MCP",
  "mcp.verify_connection": "Verify connection",
  "mcp.cli_guidance": "CLI guidance (run from your workspace)",
  "mcp.config_locations": "Config can live in opencode.json, opencode.jsonc, or .opencode/opencode.json.",
  "mcp.details_title": "Details",
  "mcp.select_server_hint": "Select a server to review status and config.",
  "mcp.capabilities_label": "Capabilities",
  "mcp.tools_enabled_label": "Tools enabled",
  "mcp.oauth_ready_label": "OAuth ready",
  "mcp.usage_hint_text": "Use the MCP server name in prompts to target its tools.",
  "mcp.next_steps_label": "Next steps",
  "mcp.reload_step": "Reload the engine after adding a server.",
  "mcp.auth_step": "Run opencode mcp auth for OAuth servers if prompted.",
  "mcp.connection_failed": "Connection failed",
  "mcp.needs_auth": "Needs auth",
  "mcp.register_client": "Register client",
  "mcp.status_disabled": "Disabled",
  "mcp.disconnected": "Disconnected",
  "mcp.failed": "Failed",
  "mcp.host_mode_only": "MCP connections are only available in Host mode.",
  "mcp.pick_workspace_first": "Pick a workspace folder first.",
  "mcp.desktop_required": "MCP connections require the desktop app.",
  "mcp.connect_server_first": "Connect to the OpenCode server first.",
  "mcp.reload_required_after_add": "Reload required to activate the new MCP.",
  "mcp.connect_failed": "Failed to connect MCP.",
  "mcp.enter_name_and_url": "Enter a server name and URL.",
  "mcp.enter_url_first": "Enter a server URL first.",
  "mcp.use_debug_command": "Use opencode mcp debug <name> to validate connection.",
  "mcp.add_failed": "Failed to add MCP.",

  // MCP Auth Modal
  "mcp.auth.open_browser_signin": "We'll open your browser to finish sign-in.",
  "mcp.auth.connect_server": "Connect {server}",
  "mcp.auth.already_connected": "Already Connected",
  "mcp.auth.already_connected_description": "{server} is already authenticated and ready to use.",
  "mcp.auth.configured_previously": "The MCP may have been configured globally or in a previous session. You can close this modal and start using the MCP tools right away.",
  "mcp.auth.reload_engine_retry": "Reload engine and retry",
  "mcp.auth.retry_now": "Retry Now",
  "mcp.auth.retry": "Retry",
  "mcp.auth.reload_before_oauth": "Reload the engine to finish setting up this MCP before starting OAuth.",
  "mcp.auth.reload_notice": "Finish setup by reloading the engine to activate this MCP. This is a required step, not an error.",
  "mcp.auth.reload_blocked": "Reload is paused while a session is running. Stop the run to finish setup.",
  "mcp.auth.reload_needed": "Finish setup by reloading the engine, then try connecting again.",
  "mcp.auth.manual_finish_title": "Remote server?",
  "mcp.auth.manual_finish_hint": "Paste the callback URL (localhost:19876) or just the code to finish connecting.",
  "mcp.auth.callback_label": "Callback URL or code",
  "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...",
  "mcp.auth.complete_connection": "Complete connection",
  "mcp.auth.callback_invalid": "Paste the callback URL or the code parameter to finish OAuth.",
  "mcp.auth.port_forward_hint": "Tip: forward the callback port if needed: ssh -L 19876:127.0.0.1:19876 user@host",
  "mcp.auth.step1_title": "Opening your browser",
  "mcp.auth.step1_description": "We'll launch {server}'s sign-in flow automatically.",
  "mcp.auth.step2_title": "Authorize OpenWork",
  "mcp.auth.step2_description": "Sign in and approve access when prompted.",
  "mcp.auth.step3_title": "Return here when you're done",
  "mcp.auth.step3_description": "We'll finish connecting as soon as authorization completes.",
  "mcp.auth.waiting_authorization": "Waiting for authorization to complete in your browser...",
  "mcp.auth.done": "Done",
  "mcp.auth.cancel": "Cancel",
  "mcp.auth.im_done": "I'm done",
  "mcp.auth.client_registration_required": "Client registration is required before OAuth can continue.",
  "mcp.auth.server_disabled": "This MCP server is disabled. Enable it and try again.",
  "mcp.auth.oauth_failed": "OAuth authentication failed.",
  "mcp.auth.authorization_still_required": "Authorization is still required. Try again to restart the flow.",
  "mcp.auth.oauth_not_supported_hint": "This could mean:\n• The MCP server doesn't advertise OAuth capabilities\n• The engine needs to reload to discover server capabilities\n• Try: opencode mcp auth {server} from the CLI",
  "mcp.auth.try_reload_engine": "{message}. Try reloading the engine first.",
  "mcp.auth.failed_to_start_oauth": "Failed to start OAuth flow",
  "mcp.auth.oauth_completed_reload": "OAuth completed. Reload the engine to activate the MCP.",

  // ==================== Settings ====================
  "settings.title": "Settings",
  "settings.connection": "Connection",
  "settings.engine_source": "Engine source",
  "settings.from_path": "From PATH",
  "settings.from_sidecar": "Bundled sidecar",
  "settings.engine_source_description": "PATH uses your installed OpenCode (default). Sidecar will use a bundled binary when available.",
  "settings.sidecar_unsupported": "Sidecar is available on Windows",
  "settings.sidecar_unavailable_detail": "Sidecar is bundled when available.",
  "settings.model": "Model",
  "settings.model_description": "Defaults + thinking controls for runs.",
  "settings.change": "Change",
  "settings.engine_path": "PATH",
  "settings.engine_sidecar": "Sidecar",
  "settings.thinking": "Thinking",
  "settings.thinking_description": "Show thinking parts (Developer mode only).",
  "settings.on": "On",
  "settings.off": "Off",
  "settings.model_variant": "Model variant",
  "settings.edit": "Edit",
  "settings.default_model": "Default model",
  "settings.session_model": "Model",
  "settings.model_description_default": "Choose from your configured providers. This selection will be used for new sessions.",
  "settings.model_description_session": "Choose from your configured providers. This selection applies to your next message.",
  "settings.search_models": "Search models…",
  "settings.showing_models": "Showing {count} of {total}",
  "settings.model_variant_prompt": "Model variant (provider-specific, e.g. high/max/minimal). Leave blank to clear.",
  "settings.model_fallback": "Fallback",
  "settings.model_default": "Default",
  "settings.model_free": "Free",
  "settings.model_reasoning": "Reasoning",
  "settings.done": "Done",
  "settings.updates": "Updates",
  "settings.updates_description": "Keep OpenWork up to date.",
  "settings.automatic_checks": "Automatic checks",
  "settings.automatic_checks_description": "Once per day (quiet)",
  "settings.update_checking": "Checking...",
  "settings.update_available": "Update available: v",
  "settings.update_downloading": "Downloading...",
  "settings.update_ready": "Ready to install: v",
  "settings.update_error": "Update check failed",
  "settings.update_uptodate": "Up to date",
  "settings.last_checked": "Last checked",
  "settings.published": "Published",
  "settings.check_update": "Check",
  "settings.install_restart": "Install & Restart",
  "settings.update_not_supported": "Updates are not supported in this environment.",
  "settings.update_desktop_only": "Updates are only available in the desktop app.",
  "settings.startup": "Startup",
  "settings.mode_label": "mode",
  "settings.switch_mode": "Switch",
  "settings.reset_startup": "Reset default startup mode",
  "settings.reset_startup_description": "This clears your saved preference and shows mode selection on next launch.",
  "settings.advanced": "Advanced",
  "settings.advanced_description": "Reset OpenWork local state to retest onboarding.",
  "settings.reset_onboarding": "Reset onboarding",
  "settings.reset_onboarding_description": "Clears OpenWork preferences and restarts the app.",
  "settings.reset_app_data": "Reset app data",
  "settings.reset_app_data_description": "More aggressive. Clears OpenWork cache + app data.",
  "settings.reset": "Reset",
  "settings.requires_typing": "Requires typing",
  "settings.will_restart": "and will restart the app.",
  "settings.reset_onboarding_title": "Reset onboarding",
  "settings.reset_app_data_title": "Reset app data",
  "settings.reset_confirmation_hint": "Type <span class=\"font-mono\">RESET</span> to confirm. OpenWork will restart.",
  "settings.reset_onboarding_warning": "Clears OpenWork local preferences and workspace onboarding markers.",
  "settings.reset_app_data_warning": "Clears OpenWork cache and app data on this device.",
  "settings.reset_stop_active_runs": "Stop active runs before resetting.",
  "settings.reset_confirmation_label": "Confirmation",
  "settings.reset_confirmation_placeholder": "Type RESET",
  "settings.reset_cancel": "Cancel",
  "settings.reset_confirm_button": "Reset & Restart",
  "settings.developer": "Developer",
  "settings.opencode_cache": "OpenCode cache",
  "settings.opencode_cache_description": "Repairs cached data used to start the engine. Safe to run.",
  "settings.repair_cache": "Repair cache",
  "settings.repairing_cache": "Repairing cache",
  "settings.cache_repair_requires_desktop": "Cache repair requires the desktop app",
  "settings.pending_permissions": "Pending permissions",
  "settings.recent_events": "Recent events",
  "settings.notion_connected": "Connected",
  "settings.reload_required": "Reload required",
  "settings.connection_failed": "Connection failed",
  "settings.notion_not_connected": "Not connected",
  "settings.show_thinking": "Show thinking",
  "settings.update": "Update",
  "settings.about": "About",
  "settings.version": "Version",
  "settings.check_for_updates": "Check for updates",
  "settings.download_update": "Download update",
  "settings.install_update": "Install update and restart",
  "settings.enable_developer_mode": "Enable Developer Mode",
  "settings.disable_developer_mode": "Disable Developer Mode",
  "settings.stop_engine": "Stop engine",
  "settings.disconnect": "Disconnect",
  "settings.language": "Language",
  "settings.language.description": "Choose your preferred language",
  "settings.connection_title": "Connection",
  "settings.engine_source_label": "Engine source",
  "settings.engine_source_hint": "PATH uses your installed OpenCode (default). Sidecar will use a bundled binary when available.",
  "settings.sidecar_unavailable": "Sidecar is bundled when available.",
  "settings.model_title": "Model",
  "settings.model_hint": "Defaults + thinking controls for runs.",
  "settings.thinking_label": "Thinking",
  "settings.thinking_hint": "Show thinking parts (Developer mode only).",
  "settings.model_variant_label": "Model variant",
  "settings.appearance_title": "Appearance",
  "settings.appearance_hint": "Match the system or force light/dark mode.",
  "settings.theme_system": "System",
  "settings.theme_light": "Light",
  "settings.theme_dark": "Dark",
  "settings.theme_system_hint": "System mode follows your OS preference automatically.",
  "settings.updates_title": "Updates",
  "settings.updates_hint": "Keep OpenWork up to date.",
  "settings.automatic_checks_label": "Automatic checks",
  "settings.automatic_checks_hint": "Once per day (quiet)",
  "settings.last_checked_time": "Last checked {time}",
  "settings.published_date": "Published {date}",
  "settings.update_not_supported_hint": "Updates are not supported in this environment.",
  "settings.update_desktop_only_hint": "Updates are only available in the desktop app.",
  "settings.startup_title": "Startup",
  "settings.mode_suffix": "mode",
  "settings.reset_startup_label": "Reset default startup mode",
  "settings.reset_startup_hint": "This clears your saved preference and shows mode selection on next launch.",
  "settings.advanced_title": "Advanced",
  "settings.advanced_hint": "Reset OpenWork local state to retest onboarding.",
  "settings.reset_onboarding_label": "Reset onboarding",
  "settings.reset_onboarding_hint": "Clears OpenWork preferences and restarts the app.",
  "settings.reset_app_data_label": "Reset app data",
  "settings.reset_app_data_hint": "More aggressive. Clears OpenWork cache + app data.",
  "settings.reset_requires_hint": "Requires typing RESET and will restart the app.",
  "settings.developer_title": "Developer",
  "settings.opencode_cache_label": "OpenCode cache",
  "settings.opencode_cache_hint": "Repairs cached data used to start the engine. Safe to run.",
  "settings.pending_permissions_label": "Pending permissions",
  "settings.recent_events_label": "Recent events",
  "settings.stop_active_runs_hint": "Stop active runs to update",
  "settings.stop_active_runs_reset_hint": "Stop active runs to reset",
  "settings.stop_runs_to_update": "Stop active runs to update",
  "settings.stop_runs_to_reset": "Stop active runs to reset",
  "settings.updates_not_supported": "Updates are not supported in this environment.",
  "settings.updates_desktop_only": "Updates are only available in the desktop app.",

  // ==================== Reload ====================
  "reload.toast_title": "Updates available",
  "reload.toast_description": "Reload workspace to apply config changes.",
  "reload.toast_warning": "Stops all active tasks.",
  "reload.toast_warning_active": "Reloading stops {count} active tasks.",
  "reload.toast_reload": "Reload",
  "reload.toast_reload_stopped": "Reload & Stop Tasks",
  "reload.toast_reloading": "Reloading...",
  "reload.toast_dismiss": "Later",
  "reload.toast_blocked_host": "Reloading is only available in Host mode.",
  "reload.toast_blocked_connect": "Connect to this workspace to reload.",
  "reload.toast_blocked_runs": "Waiting for active tasks to complete before reloading.",

  // ==================== Onboarding ====================
  "onboarding.starting_host": "Starting OpenWork...",
  "onboarding.searching_host": "Searching for Host...",
  "onboarding.getting_ready": "Getting everything ready",
  "onboarding.verifying": "Verifying secure handshake",
  "onboarding.create_first_workspace": "Create your first workspace",
  "onboarding.create_workspace": "Create a workspace",
  "onboarding.workspace_description": "Choose a folder and preset to set up your workspace.",
  "onboarding.start": "Start OpenWork",
  "onboarding.back": "Back",
  "onboarding.advanced_settings": "Advanced settings",
  "onboarding.opencode_engine": "OpenCode engine",
  "onboarding.refresh": "Refresh",
  "onboarding.checking_cli": "Checking OpenCode CLI...",
  "onboarding.cli_not_found": "OpenCode CLI not found.",
  "onboarding.cli_needs_update": "OpenCode CLI needs an update for serve.",
  "onboarding.opencode": "OpenCode",
  "onboarding.cli_ready": "OpenCode CLI ready.",
  "onboarding.cli_version": "OpenCode {version}",
  "onboarding.windows_install_instruction": "Install OpenCode for Windows, then restart OpenWork. Ensure opencode.exe is on PATH.",
  "onboarding.install_instruction": "Install OpenCode to enable host mode (no terminal required).",
  "onboarding.install": "Install OpenCode",
  "onboarding.recheck": "Re-check",
  "onboarding.ready_message": "OpenCode is ready to start in host mode.",
  "onboarding.resolved_path": "Resolved path",
  "onboarding.version": "Version",
  "onboarding.search_notes": "Search notes",
  "onboarding.serve_help": "serve --help output",
  "onboarding.workspace_folder_label": "A workspace is a folder with its own skills, plugins, and commands.",
  "onboarding.theme_label": "Theme",
  "onboarding.theme_current": "Current: {mode}",
  "onboarding.theme_system": "System",
  "onboarding.theme_light": "Light",
  "onboarding.theme_dark": "Dark",
  "onboarding.access_label": "Access",
  "onboarding.folders_allowed": "{count} folder{plural} allowed",
  "onboarding.manage_access_hint": "You can manage access in advanced settings.",
  "onboarding.open_settings_hint": "Need engine or access options? Open Settings.",
  "onboarding.open_settings": "Open Settings",
  "onboarding.add_folder_path": "Add folder path",
  "onboarding.pick": "Pick",
  "onboarding.add": "Add",
  "onboarding.remove": "Remove",
  "onboarding.cli_label": "OpenCode CLI",
  "onboarding.cli_checking": "Checking install...",
  "onboarding.cli_not_found_hint": "Not found. Install to run Host mode.",
  "onboarding.cli_version_installed": "Installed",
  "onboarding.cli_recheck": "Re-check",
  "onboarding.cli_install_commands": "Install OpenCode with one of the commands below, then restart OpenWork.",
  "onboarding.show_search_notes": "Show search notes",
  "onboarding.last_checked": "Last checked {time}",
  "onboarding.server_url_placeholder": "http://localhost:8088",
  "onboarding.directory_placeholder": "my-project",
  "onboarding.connect_host": "Connect to Host",
  "onboarding.connect_description": "Pair with an existing OpenCode server (LAN or tunnel).",
  "onboarding.server_url": "Server URL",
  "onboarding.directory": "Directory (optional)",
  "onboarding.directory_hint": "Use if your host runs multiple workspaces.",
  "onboarding.connect": "Connect",
  "onboarding.remote_workspace_title": "Connect OpenCode (recommended)",
  "onboarding.remote_workspace_description": "OpenWork connects directly by default. Use an OpenWork host to pair devices.",
  "onboarding.remote_workspace_action": "Connect",
  "onboarding.remote_workspace_card_title": "Connect a remote workspace",
  "onboarding.remote_workspace_card_description": "Connect to OpenCode directly or pair with an OpenWork host.",
  "onboarding.advanced_openwork_host": "OpenWork host (alpha)",
  "onboarding.advanced_openwork_hint": "Use a host URL and client token for shared access.",
  "onboarding.advanced_opencode_direct": "Advanced: OpenCode direct",
  "onboarding.advanced_opencode_hint": "Connect straight to an OpenCode engine when no host is available.",
  "onboarding.welcome_title": "How would you like to run OpenWork today?",
  "onboarding.run_local": "Run on this computer",
  "onboarding.run_local_description": "OpenWork runs OpenCode locally and keeps your work private.",
  "onboarding.engine_running": "Engine already running",
  "onboarding.attach_description": "Attach to the existing session on this device.",
  "onboarding.attach": "Attach",
  "onboarding.remember_choice": "Remember my choice for next time",
  "onboarding.client_mode": "Connect as a Client (Remote Pairing)",
  "onboarding.default_workspace_path": "~/OpenWork/Workspace",
  "onboarding.authorize_folder": "Authorize folder",
  "onboarding.choose_workspace_folder": "Choose workspace folder",

  // ==================== Common ====================
  "common.alpha": "Alpha",
  "common.change": "Change",
  "common.refresh": "Refresh",
  "common.new": "New",
  "common.install": "Install",
  "common.delete": "Delete",
  "common.edit": "Edit",
  "common.save": "Save",
  "common.cancel": "Cancel",
  "common.close": "Close",
  "common.open": "Open",
  "common.show": "Show",
  "common.hide": "Hide",
  "common.path": "Path",
  "common.choose": "Choose",
  "common.retry": "Retry",
  "common.untitled": "Untitled",
  "common.default_parens": "(default)",
  "common.on": "On",
  "common.off": "Off",

  // ==================== Status ====================
  "status.connected": "Connected",
  "status.disconnected": "Disconnected",
  "status.idle": "Idle",
  "status.busy": "Busy",
  "status.running": "Running",
  "status.live": "Live",
  "status.connecting": "Connecting",
  "status.creating_workspace": "Creating workspace",
  "status.deleting_command": "Deleting command",
  "status.saving_workspace_command": "Saving workspace command",
  "status.saving_command": "Saving command",
  "status.loading_session": "Loading session",
  "status.creating_task": "Creating new task",
  "status.starting_engine": "Starting engine",
  "status.reloading_engine": "Reloading engine",
  "status.restarting_engine": "Restarting engine",
  "status.installing_opencode": "Installing OpenCode",
  "status.disconnecting": "Disconnecting",

  // ==================== Workspace Switching ====================
  "workspace.switching_title": "Opening {name}",
  "workspace.switching_title_unknown": "Opening workspace",
  "workspace.switching_subtitle": "We’ll bring your recent work back.",
  "workspace.switching_status_preparing": "Getting things ready",
  "workspace.switching_status_connecting": "Checking your connection",
  "workspace.switching_status_loading": "Loading recent tasks",
  "workspace.switching_status_almost": "Almost there",

  "app.connection_lost": "Server connection lost. Please reload.",
  "app.unknown_error": "Unknown error",
  "app.error.tauri_required": "This action requires the Tauri app runtime.",
  "app.error.choose_folder": "Choose a folder to continue.",
  "app.error.pick_workspace_folder": "Pick a workspace folder first.",
  "app.error.remote_base_url_required": "Add a server URL to continue.",
  "app.error.host_requires_local": "Select a local workspace to start the engine.",
  "app.error.sidecar_unsupported_windows": "Sidecar OpenCode is bundled on Windows when available. Falling back to PATH.",
  "app.error.install_failed": "OpenCode install failed. See logs above.",
  "app.error.command_name_template_required": "Command name and instructions are required.",
  "app.error.workspace_commands_desktop": "Commands require the desktop app.",
  "app.error.command_scope_unknown": "This command can't be managed in this mode.",
} as const;



================================================
FILE: packages/app/src/i18n/locales/index.ts
================================================
/**
 * Re-export all translation files for convenience
 */
export { default as en } from "./en";
export { default as zh } from "./zh";



================================================
FILE: packages/app/src/i18n/locales/zh.ts
================================================
/**
 * 中文（简体）翻译
 * 专业术语不翻译：Skills、Plugins、Commands、Sessions、OpenCode、OpenPackage、OpenWork、MCPs
 */

export default {
  // ==================== Dashboard ====================
  "dashboard.title": "主页",
  "dashboard.sessions": "会话",
  "dashboard.commands": "Commands",
  "dashboard.skills": "Skills",
  "dashboard.plugins": "插件",
  "dashboard.mcps": "MCPs",
  "dashboard.settings": "设置",
  "dashboard.home": "主页",
  "dashboard.runs": "运行",
  "dashboard.find_workspace": "查找工作区...",
  "dashboard.workspaces": "工作区",
  "dashboard.new_workspace": "新建工作区...",
  "dashboard.new_remote_workspace": "添加远程工作区...",
  "dashboard.forget_workspace": "忘记工作区",
  "dashboard.remote": "远程",
  "dashboard.connection": "连接",
  "dashboard.local_engine": "本地引擎",
  "dashboard.client_mode": "客户端模式",
  "dashboard.connected": "已连接",
  "dashboard.not_connected": "未连接",
  "dashboard.stop_disconnect": "停止并断开",
  "dashboard.disconnect": "断开连接",
  "dashboard.new_task": "新建任务",
  "dashboard.new": "新建",
  "dashboard.busy": "忙碌",
  "dashboard.hero_title": "今天想做些什么？",
  "dashboard.hero_description": "描述一个目标。OpenWork 将运行它并保留审计记录。",
  "dashboard.quick_start_commands": "快速开始命令",
  "dashboard.view_all": "查看全部",
  "dashboard.no_commands": "还没有命令。初始命令将显示在这里。",
  "dashboard.run_command": "运行保存的命令",
  "dashboard.recent_sessions": "最近的会话",
  "dashboard.this_workspace": "此工作区",
  "dashboard.no_sessions": "还没有会话。",
  "dashboard.idle": "空闲",
  "dashboard.running": "运行中",
  "dashboard.completed": "已完成",
  "dashboard.failed": "失败",
  "dashboard.repairing_cache": "正在修复缓存",
  "dashboard.repair_cache": "修复缓存",
  "dashboard.retry": "重试",
  "dashboard.alpha": "测试版",
  "dashboard.create_workspace_title": "创建工作区",
  "dashboard.create_workspace_subtitle": "初始化新的基于文件夹的工作区。",
  "dashboard.create_workspace_confirm": "创建工作区",
  "dashboard.create_remote_workspace_title": "添加远程工作区",
  "dashboard.create_remote_workspace_subtitle": "将 OpenWork 主机或 OpenCode 引擎保存为工作区。",
  "dashboard.create_remote_workspace_confirm": "添加工作区",
  "dashboard.remote_workspace_title": "远程工作区",
  "dashboard.remote_workspace_hint": "跟踪 OpenWork 主机或 OpenCode 引擎，随时重新连接。",
  "dashboard.remote_base_url_label": "OpenCode 地址",
  "dashboard.remote_base_url_placeholder": "http://127.0.0.1:4096",
  "dashboard.remote_base_url_required": "请先填写服务器地址。",
  "dashboard.openwork_host_label": "OpenWork 主机地址",
  "dashboard.openwork_host_placeholder": "https://your-host.openwork.app",
  "dashboard.openwork_host_hint": "使用 OpenWork 主机提供的地址。",
  "dashboard.openwork_host_token_label": "客户端令牌",
  "dashboard.openwork_host_token_placeholder": "粘贴你的令牌",
  "dashboard.openwork_host_token_hint": "可选。粘贴主机提供的客户端令牌以完成配对。",
  "dashboard.remote_mode_openwork_alpha": "OpenWork 主机（Alpha）",
  "dashboard.remote_mode_direct": "OpenCode 直连（推荐）",
  "dashboard.remote_connection_openwork": "OpenWork",
  "dashboard.remote_connection_direct": "直连",
  "dashboard.remote_directory_label": "工作区目录（可选）",
  "dashboard.remote_directory_placeholder": "/home/team/project",
  "dashboard.remote_directory_hint": "留空则使用服务器默认目录。",
  "dashboard.remote_display_name_label": "显示名称（可选）",
  "dashboard.remote_display_name_placeholder": "设计团队工作区",
  "dashboard.select_folder": "选择文件夹",
  "dashboard.choose_folder": "选择文件夹",
  "dashboard.choose_folder_next": "接下来您将选择一个目录。",
  "dashboard.change": "更改",
  "dashboard.opening": "正在打开...",
  "dashboard.choose_preset": "选择预设",
  "dashboard.choose_folder_continue": "选择文件夹以继续。",
  "dashboard.starter_workspace": "启动工作区",
  "dashboard.starter_workspace_desc": "预配置以展示如何使用插件、命令和技能。",
  "dashboard.empty_workspace": "空白工作区",
  "dashboard.empty_workspace_desc": "从空白文件夹开始，添加您需要的内容。",

  // ==================== Session ====================
  "session.no_selected": "未选择会话",
  "session.back_to_dashboard": "返回仪表盘",
  "session.new_task": "新建任务",
  "session.recents": "最近",
  "session.recents_notice": "这些任务在本地运行，不会跨设备同步。",
  "session.ready_to_work_title": "准备就绪",
  "session.ready_to_work_description": "描述一个任务。我会显示进度并在需要时请求权限。",
  "session.document_label": "文档",
  "session.standard_label": "标准",
  "session.no_artifacts_fallback": "还没有工件。",
  "session.active_plugins_label": "活动插件",
  "session.active_plugins_count": "{count}",
  "session.no_plugins_loaded": "未加载插件。",
  "session.selected_folders_label": "选中的文件夹",
  "session.selected_folders_count": "{count}",
  "session.working_files_label": "工作文件",
  "session.none_yet_label": "还没有。",
  "session.permission_required_title": "需要权限",
  "session.permission_required_description": "OpenCode 正在请求权限以继续。",
  "session.permission_label_uppercase": "权限",
  "session.scope_label_uppercase": "范围",
  "session.details_label": "详情",
  "session.steps_notice_text": "步骤将随着任务的展开而显示。",
  "session.ready_to_work": "准备就绪",
  "session.ready_description": "描述一个任务。我会显示进度并在需要时请求权限。",
  "session.hide_steps": "隐藏步骤",
  "session.view_steps": "查看步骤",
  "session.open": "打开",
  "session.reveal": "在文件管理器中显示",
  "session.opened_toast": "已在默认应用中打开。",
  "session.revealed_toast": "已在文件管理器中显示。",
  "session.artifact_path_missing": "工件路径缺失。",
  "session.desktop_only": "打开功能仅在桌面应用中可用。",
  "session.open_failed": "无法打开工件。",
  "session.model": "模型",
  "session.ready": "就绪",
  "session.connect_provider": "连接提供商以进行自定义。",
  "session.running": "运行中",
  "session.progress": "进度",
  "session.steps_notice": "步骤将随着任务的展开而显示。",
  "session.artifacts": "工件",
  "session.no_artifacts": "还没有工件。",
  "session.context": "上下文",
  "session.active_plugins": "活动插件",
  "session.no_plugins": "未加载插件。",
  "session.selected_folders": "选中的文件夹",
  "session.working_files": "工作文件",
  "session.none_yet": "还没有。",
  "session.document": "文档",
  "session.standard": "标准",
  "session.try_notion_prompt": "立即尝试：在 Notion 中设置我的 CRM",
  "session.insert_prompt": "插入提示",
  "session.placeholder": "向 OpenWork 提问...",
  "session.run": "运行",
  "session.permission_required": "需要权限",
  "session.permission_description": "OpenCode 正在请求权限以继续。",
  "session.permission_label": "权限",
  "session.scope_label": "范围",
  "session.details": "详情",
  "session.deny": "拒绝",
  "session.once": "一次",
  "session.allow_for_session": "在会话期间允许",

  "session.tasks_local_hint": "这些任务在本地运行，不会跨设备同步。",
  "session.recents_label": "最近",
  "session.model_standard": "标准",
  "session.run_button_title": "运行",
  "session.rename_title": "重命名会话",
  "session.rename_description": "更新此会话名称。",
  "session.rename_label": "会话名称",
  "session.rename_placeholder": "输入新的名称",

  // ==================== Commands ====================
  "commands.new": "新建",
  "commands.empty_state": "保存提示词或命令，一键重复运行。",
  "commands.workspace": "此工作区",
  "commands.global": "所有工作区",
  "commands.other": "其他命令",
  "commands.run": "运行",
  "commands.modal_title": "保存可复用流程",
  "commands.modal_description": "保存提示词或命令，便于快速复用。",
  "commands.name_label": "命令名称",
  "commands.name_placeholder": "例如：daily-standup",
  "commands.name_hint": "将在 OpenCode 中显示为 /daily-standup。",
  "commands.description_label": "描述（可选）",
  "commands.description_placeholder": "这个命令做什么？",
  "commands.template_label": "指令",
  "commands.template_placeholder": "编写你想复用的指令…",
  "commands.template_hint": "使用 $ARGUMENTS 来接收详情参数。",
  "commands.details_required": "详情",
  "commands.default_description": "运行保存的命令",
  "commands.command_label": "命令",
  "commands.details_label": "详情",
  "commands.details_placeholder": "添加可选详情",
  "commands.details_hint": "这些详情将传给命令。",
  "commands.run_modal_title": "运行命令",
  "commands.run_modal_description": "运行前可添加详情。",
  "commands.run_modal_run": "运行命令",
  "commands.name_will_be": "将创建为",
  "commands.override_title": "替换现有命令？",
  "commands.override_description": "已存在同名命令。",
  "commands.override_warning": "这将替换现有的 \"{name}\" 命令。此操作无法撤销。",
  "commands.override_confirm": "替换",
  "commands.override_cancel": "保留现有",

  // ==================== Skills ====================
  "skills.title": "Skills",
  "skills.subtitle": "管理此工作区的 skills。",
  "skills.refresh": "刷新",
  "skills.add_title": "添加 skills",
  "skills.add_description": "安装起始命令、导入 skill，或打开文件夹。",
  "skills.install_from_openpackage": "从 OpenPackage 安装",
  "skills.host_mode_only": "仅主机模式",
  "skills.install": "安装",
  "skills.installed_label": "已安装",
  "skills.install_hint": "将 OpenPackage 包安装到当前工作区。Skills 应放在 `.opencode/skills` 中。",
  "skills.import_local": "导入本地 skill",
  "skills.import_local_hint": "将现有 skill 文件夹复制到此工作区。",
  "skills.import": "导入",
  "skills.curated_packages": "精选包",
  "skills.view": "查看",
  "skills.search_placeholder": "搜索包或列表（例如：claude、registry、community）",
  "skills.no_matches": "没有匹配的精选包。尝试不同的搜索。",
  "skills.install_package": "安装",
  "skills.registry_notice": "发布到 OpenPackage 注册表（`opkg push`）目前需要身份验证。计划添加注册表搜索和精选列表同步。",
  "skills.installed": "已安装的 skills",
  "skills.no_skills": "在 `.opencode/skills` 或 `.claude/skills` 中未检测到 skills。",
  "skills.desktop_required": "技能管理需要桌面应用。",
  "skills.host_only_error": "技能管理仅在主机模式下可用。",
  "skills.install_skill_creator": "安装 skill creator",
  "skills.install_skill_creator_hint": "此 skill 可让你在聊天中创建其他 skills。",
  "skills.installing_skill_creator": "正在安装 skill creator...",
  "skills.skill_creator_installed": "Skill creator 已安装。",
  "skills.skill_creator_already_installed": "Skill creator 已经安装过了。",
  "skills.install_failed": "技能安装失败。",
  "skills.reveal_folder": "打开 skills 文件夹",
  "skills.reveal_folder_hint": "在 Finder 中打开 skills 目录。",
  "skills.reveal_button": "在 Finder 中显示",
  "skills.reveal_failed": "打开 skills 文件夹失败。",
  "skills.uninstall": "卸载",
  "skills.uninstall_title": "卸载 skill？",
  "skills.uninstall_warning": "这将永久从工作区删除 `{name}` skill。",
  "skills.uninstall_failed": "卸载 skill 失败。",
  "skills.uninstalled": "Skill 已删除。",
  "skills.source_placeholder": "github:anthropics/claude-code",
  "skills.notion_crm_title": "Notion CRM 增强技能",
  "skills.notion_crm_description": "为联系人、管道和跟进添加增强工作流。",
  "skills.notion_crm_card_description": "使用现成的技能丰富 Notion CRM 数据。",
  "skills.connect_host_to_load": "连接到主机以加载 skills。",
  "skills.pick_workspace_first": "先选择一个工作区文件夹。",
  "skills.no_skills_found": "还没有找到 skills。",
  "skills.installed_description": "此工作区可用的 skills。",
  "skills.failed_to_load": "加载 skills 失败",
  "skills.plugin_management_host_only": "插件管理仅在主机模式下可用。",
  "skills.plugins_host_only": "插件仅在主机模式下可用。",
  "skills.pick_project_for_plugins": "选择项目文件夹以管理项目插件。",
  "skills.pick_project_for_active": "选择项目文件夹以加载活动插件。",
  "skills.no_opencode_found": "尚未找到 opencode.json。添加插件以创建一个。",
  "skills.no_opencode_workspace": "此工作区中还没有 opencode.json。",
  "skills.failed_parse_opencode": "解析 opencode.json 失败",
  "skills.failed_load_opencode": "加载 opencode.json 失败",
  "skills.failed_load_active": "加载活动插件失败。",
  "skills.enter_plugin_name": "输入插件包名称。",
  "skills.plugin_already_listed": "插件已在 opencode.json 中列出。",
  "skills.failed_update_opencode": "更新 opencode.json 失败",
  "skills.opackage_install_host_only": "OpenPackage 安装仅在主机模式下可用。",
  "skills.pick_project_first": "先选择一个项目文件夹。",
  "skills.enter_opackage_source": "输入 OpenPackage 源（例如：github:anthropics/claude-code）。",
  "skills.installing_opackage": "正在安装 OpenPackage...",
  "skills.install_complete": "已安装。",
  "skills.curated_list_notice": "这是一个精选列表，还不是 OpenPackage。复制链接或关注 PRD 以了解计划的注册表搜索集成。",
  "skills.import_host_only": "Skill 导入仅在主机模式下可用。",
  "skills.select_skill_folder": "选择 skill 文件夹",
  "skills.import_failed": "导入失败（{status}）",
  "skills.imported": "已导入。",
  "skills.unknown_error": "未知错误",

  // ==================== Plugins ====================
  "plugins.title": "OpenCode 插件",
  "plugins.description": "为您的项目或全局 OpenCode 插件管理 `opencode.json`。",
  "plugins.config_label": "配置",
  "plugins.config_not_loaded": "尚未加载",
  "plugins.suggested_label": "建议的插件",
  "plugins.no_plugins_yet": "尚未配置插件。",
  "plugins.enabled_label": "已启用",
  "plugins.open_label": "打开",
  "plugins.path_label": "路径",
  "plugins.scope_project": "项目",
  "plugins.scope_global": "全局",
  "plugins.refresh": "刷新",
  "plugins.config": "配置",
  "plugins.not_loaded": "尚未加载",
  "plugins.suggested": "建议的插件",
  "plugins.hide_setup": "隐藏设置",
  "plugins.setup": "设置",
  "plugins.added": "已添加",
  "plugins.add": "添加",
  "plugins.enabled": "已启用",
  "plugins.no_plugins": "尚未配置插件。",
  "plugins.add_label": "添加插件",
  "plugins.placeholder": "opencode-wakatime",
  "plugins.add_hint": "添加 npm 包名称，例如 opencode-wakatime",

  // ==================== MCP (Model Context Protocol) ====================
  "mcp.title": "MCP（测试版）",
  "mcp.description": "MCP 服务器让您使用自己的凭据连接服务。",
  "mcp.alpha_banner_title": "MCP 处于测试阶段，我们正在加强与 OpenCode 的 OAuth 集成。",
  "mcp.alpha_banner_help": "如果您想帮忙，请提交 PR 并附上一个短视频，展示 OAuth 流程从端到端的工作。",
  "mcp.mcps_title": "MCPs",
  "mcp.connect_mcp_hint": "连接 MCP 服务器以扩展 OpenWork 的功能。",
  "mcp.reload_banner_title": "需要重新加载",
  "mcp.reload_banner_description": "更改需要快速重新加载以激活 MCP 工具。",
  "mcp.quick_connect_title": "快速连接",
  "mcp.oauth_only_label": "OAuth + 本地",
  "mcp.connected_status": "已连接",
  "mcp.no_env_vars": "不需要环境变量。",
  "mcp.connected_title": "已连接",
  "mcp.from_opencode_json": "来自 opencode.json",
  "mcp.no_servers_yet": "尚未配置 MCP 服务器。",
  "mcp.edit_config_title": "编辑 MCP 配置",
  "mcp.edit_config_description": "MCP 服务器位于 OpenCode 的 opencode.json 中。",
  "mcp.docs_link": "文档",
  "mcp.scope_project": "项目",
  "mcp.scope_global": "全局",
  "mcp.config_label": "配置",
  "mcp.config_not_loaded": "尚未加载",
  "mcp.open_file_label": "打开文件",
  "mcp.reveal_in_finder": "在文件管理器中显示",
  "mcp.opening_label": "正在打开",
  "mcp.file_not_found": "文件未找到",
  "mcp.config_load_failed": "加载配置路径失败",
  "mcp.open_file": "打开文件",
  "mcp.pick_workspace_error": "选择工作区文件夹以显示项目 opencode.json。",
  "mcp.reveal_config_failed": "显示配置失败",
  "mcp.alpha_warning": "MCP 处于测试阶段，我们正在加强与 OpenCode 的 OAuth 集成。",
  "mcp.github_issue": "在 GitHub 上查看 issue #9510",
  "mcp.contribution_guide": "如果您想帮忙，请提交 PR 并附上一个短视频，展示 OAuth 流程从端到端的工作。",
  "mcp.hide_advanced": "隐藏高级设置",
  "mcp.show_advanced": "显示高级设置",
  "mcp.mcps_label": "MCPs",
  "mcp.mcps_description": "连接 MCP 服务器以扩展 OpenWork 的功能。",
  "mcp.configured": "已配置",
  "mcp.updated": "更新时间",
  "mcp.reload_required": "需要重新加载",
  "mcp.reload_description": "更改需要快速重新加载以激活 MCP 工具。",
  "mcp.reload_engine": "重新加载引擎",
  "mcp.quick_connect": "快速连接",
  "mcp.oauth_only": "仅 OAuth",
  "mcp.connecting": "连接中",
  "mcp.connect": "连接",
  "mcp.connected": "已连接",
  "mcp.connected_label": "已连接",
  "mcp.no_env_required": "不需要环境变量。",
  "mcp.config_source": "来自 opencode.json",
  "mcp.no_servers": "尚未配置 MCP 服务器。",
  "mcp.advanced": "高级",
  "mcp.advanced_description": "手动设置自定义服务器。",
  "mcp.hide": "隐藏",
  "mcp.show": "显示",
  "mcp.server_name": "服务器名称",
  "mcp.server_name_placeholder": "sentry",
  "mcp.server_url": "服务器 URL",
  "mcp.server_url_placeholder": "https://mcp.sentry.dev/mcp",
  "mcp.oauth": "OAuth",
  "mcp.api_key": "API 密钥",
  "mcp.enabled": "已启用",
  "mcp.disabled": "已禁用",
  "mcp.add_mcp": "添加 MCP",
  "mcp.verify_connection": "验证连接",
  "mcp.cli_guidance": "CLI 指南（从您的工作区运行）",
  "mcp.config_locations": "配置可以位于 opencode.json、opencode.jsonc 或 .opencode/opencode.json。",
  "mcp.details_title": "详情",
  "mcp.select_server": "选择服务器",
  "mcp.select_server_hint": "选择服务器以查看状态和配置。",
  "mcp.capabilities": "功能",
  "mcp.capabilities_label": "功能",
  "mcp.tools_enabled": "已启用工具",
  "mcp.tools_enabled_label": "已启用工具",
  "mcp.oauth_ready": "OAuth 就绪",
  "mcp.oauth_ready_label": "OAuth 就绪",
  "mcp.usage_hint": "在提示中使用 MCP 服务器名称来定位其工具。",
  "mcp.usage_hint_text": "在提示中使用 MCP 服务器名称来定位其工具。",
  "mcp.next_steps": "后续步骤",
  "mcp.next_steps_label": "后续步骤",
  "mcp.reload_step": "添加服务器后重新加载引擎。",
  "mcp.auth_step": "如果提示，为 OAuth 服务器运行 opencode mcp auth。",
  "mcp.connection_failed": "连接失败",
  "mcp.needs_auth": "需要认证",
  "mcp.register_client": "注册客户端",
  "mcp.status_disabled": "已禁用",
  "mcp.disconnected": "已断开",
  "mcp.failed": "失败",
  "mcp.host_mode_only": "MCP 连接仅在主机模式下可用。",
  "mcp.pick_workspace_first": "先选择一个工作区文件夹。",
  "mcp.desktop_required": "MCP 连接需要桌面应用。",
  "mcp.connect_server_first": "先连接到 OpenCode 服务器。",
  "mcp.reload_required_after_add": "添加 MCP 后需要重新加载。",
  "mcp.connect_failed": "MCP 连接失败。",
  "mcp.enter_name_and_url": "输入服务器名称和 URL。",
  "mcp.enter_url_first": "先输入服务器 URL。",
  "mcp.use_debug_command": "使用 opencode mcp debug <name> 验证连接。",
  "mcp.add_failed": "添加 MCP 失败。",

  // MCP Auth Modal
  "mcp.auth.open_browser_signin": "我们将打开您的浏览器完成登录。",
  "mcp.auth.connect_server": "连接 {server}",
  "mcp.auth.already_connected": "已连接",
  "mcp.auth.already_connected_description": "{server} 已通过身份验证，可以正常使用。",
  "mcp.auth.configured_previously": "该 MCP 可能在全局或之前的会话中已配置。您可以关闭此弹窗，立即开始使用 MCP 工具。",
  "mcp.auth.reload_engine_retry": "重新加载引擎并重试",
  "mcp.auth.retry_now": "立即重试",
  "mcp.auth.retry": "重试",
  "mcp.auth.step1_title": "正在打开您的浏览器",
  "mcp.auth.step1_description": "我们将自动启动 {server} 的登录流程。",
  "mcp.auth.step2_title": "授权 OpenWork",
  "mcp.auth.step2_description": "登录并在提示时批准访问权限。",
  "mcp.auth.step3_title": "完成后返回此处",
  "mcp.auth.step3_description": "授权完成后我们将立即完成连接。",
  "mcp.auth.waiting_authorization": "正在等待您在浏览器中完成授权...",
  "mcp.auth.done": "完成",
  "mcp.auth.cancel": "取消",
  "mcp.auth.im_done": "我已完成",
  "mcp.auth.client_registration_required": "在继续 OAuth 之前需要先注册客户端。",
  "mcp.auth.server_disabled": "此 MCP 服务器已禁用。请启用它后重试。",
  "mcp.auth.oauth_failed": "OAuth 身份验证失败。",
  "mcp.auth.authorization_still_required": "仍需要授权。请重试以重新启动流程。",
  "mcp.auth.oauth_not_supported_hint": "这可能意味着：\n• MCP 服务器未声明 OAuth 功能\n• 引擎需要重新加载以发现服务器功能\n• 尝试：从 CLI 运行 opencode mcp auth {server}",
  "mcp.auth.try_reload_engine": "{message}。请尝试先重新加载引擎。",
  "mcp.auth.failed_to_start_oauth": "启动 OAuth 流程失败",
  "mcp.auth.oauth_completed_reload": "OAuth 已完成。重新加载引擎以激活 MCP。",

  // ==================== Settings ====================
  "settings.title": "设置",
  "settings.connection": "连接",
  "settings.engine_source": "引擎来源",
  "settings.from_path": "从 PATH",
  "settings.from_sidecar": "内置 sidecar",
  "settings.engine_source_description": "PATH 使用您安装的 OpenCode（默认）。Sidecar 将在可用时使用内置的二进制文件。",
  "settings.sidecar_unsupported": "Sidecar 已支持 Windows",
  "settings.sidecar_unavailable_detail": "Sidecar 可用时会随应用内置。",
  "settings.model": "模型",
  "settings.model_description": "默认值 + 运行的思考控制。",
  "settings.change": "更改",
  "settings.engine_path": "PATH",
  "settings.engine_sidecar": "Sidecar",
  "settings.thinking": "思考",
  "settings.thinking_description": "显示思考部分（仅开发者模式）。",
  "settings.on": "开启",
  "settings.off": "关闭",
  "settings.model_variant": "模型变体",
  "settings.edit": "编辑",
  "settings.default_model": "默认模型",
  "settings.session_model": "模型",
  "settings.model_description_default": "从您配置的提供商中选择。此选择将用于新会话。",
  "settings.model_description_session": "从您配置的提供商中选择。此选择适用于您的下一条消息。",
  "settings.search_models": "搜索模型…",
  "settings.showing_models": "显示 {count} / {total}",
  "settings.model_variant_prompt": "模型变体（提供商特定，例如 high/max/minimal）。留空则清除。",
  "settings.model_fallback": "备用",
  "settings.model_default": "默认",
  "settings.model_free": "免费",
  "settings.model_reasoning": "推理",
  "settings.done": "完成",
  "settings.updates": "更新",
  "settings.updates_description": "保持 OpenWork 为最新版本。",
  "settings.automatic_checks": "自动检查",
  "settings.automatic_checks_description": "每天一次（安静）",
  "settings.update_checking": "检查中...",
  "settings.update_available": "可用更新：v",
  "settings.update_downloading": "下载中...",
  "settings.update_ready": "准备安装：v",
  "settings.update_error": "更新检查失败",
  "settings.update_uptodate": "已是最新",
  "settings.last_checked": "上次检查",
  "settings.published": "发布时间",
  "settings.check_update": "检查",
  "settings.install_restart": "安装并重启",
  "settings.update_not_supported": "此环境不支持更新。",
  "settings.update_desktop_only": "更新仅在桌面应用中可用。",
  "settings.startup": "启动",
  "settings.mode_label": "模式",
  "settings.switch_mode": "切换",
  "settings.reset_startup": "重置默认启动模式",
  "settings.reset_startup_description": "这将清除您保存的偏好设置，并在下次启动时显示模式选择。",
  "settings.advanced": "高级",
  "settings.advanced_description": "重置 OpenWork 本地状态以重新测试入门流程。",
  "settings.reset_onboarding": "重置入门",
  "settings.reset_onboarding_description": "清除 OpenCode 偏好设置并重启应用。",
  "settings.reset_app_data": "重置应用数据",
  "settings.reset_app_data_description": "更激进的方式。清除 OpenCode 缓存 + 应用数据。",
  "settings.reset": "重置",
  "settings.requires_typing": "需要输入",
  "settings.will_restart": "并将重启应用。",
  "settings.reset_onboarding_title": "重置入门",
  "settings.reset_app_data_title": "重置应用数据",
  "settings.reset_confirmation_hint": "输入 <span class=\"font-mono\">RESET</span> 以确认。OpenWork 将重启。",
  "settings.reset_onboarding_warning": "清除 OpenWork 本地偏好设置和工作区入门标记。",
  "settings.reset_app_data_warning": "清除此设备上的 OpenWork 缓存和应用数据。",
  "settings.reset_stop_active_runs": "重置前停止活动运行。",
  "settings.reset_confirmation_label": "确认",
  "settings.reset_confirmation_placeholder": "输入 RESET",
  "settings.reset_cancel": "取消",
  "settings.reset_confirm_button": "重置并重启",
  "settings.developer": "开发者",
  "settings.opencode_cache": "OpenCode 缓存",
  "settings.opencode_cache_description": "修复用于启动引擎的缓存数据。安全运行。",
  "settings.repair_cache": "修复缓存",
  "settings.repairing_cache": "正在修复缓存",
  "settings.cache_repair_requires_desktop": "缓存修复需要桌面应用",
  "settings.pending_permissions": "待处理的权限",
  "settings.recent_events": "最近的事件",
  "settings.notion_connected": "已连接",
  "settings.reload_required": "需要重新加载",
  "settings.connection_failed": "连接失败",
  "settings.notion_not_connected": "未连接",
  "settings.show_thinking": "显示思考过程",
  "settings.update": "更新",
  "settings.about": "关于",
  "settings.version": "版本",
  "settings.check_for_updates": "检查更新",
  "settings.download_update": "下载更新",
  "settings.install_update": "安装更新并重启",
  "settings.enable_developer_mode": "启用开发者模式",
  "settings.disable_developer_mode": "禁用开发者模式",
  "settings.stop_engine": "停止引擎",
  "settings.disconnect": "断开连接",
  "settings.language": "语言",
  "settings.language.description": "选择您的首选语言",
  "settings.connection_title": "连接",
  "settings.engine_source_label": "引擎来源",
  "settings.engine_source_hint": "PATH 使用您安装的 OpenCode（默认）。Sidecar 将在可用时使用内置的二进制文件。",
  "settings.sidecar_unavailable": "Sidecar 可用时会随应用内置。",
  "settings.model_title": "模型",
  "settings.model_hint": "默认值 + 运行的思考控制。",
  "settings.thinking_label": "思考",
  "settings.thinking_hint": "显示思考部分（仅开发者模式）。",
  "settings.model_variant_label": "模型变体",
  "settings.appearance_title": "外观",
  "settings.appearance_hint": "匹配系统或强制浅色/深色模式。",
  "settings.theme_system": "系统",
  "settings.theme_light": "浅色",
  "settings.theme_dark": "深色",
  "settings.theme_system_hint": "系统模式自动跟随您的操作系统偏好。",
  "settings.updates_title": "更新",
  "settings.updates_hint": "保持 OpenWork 为最新版本。",
  "settings.automatic_checks_label": "自动检查",
  "settings.automatic_checks_hint": "每天一次（安静）",
  "settings.last_checked_time": "上次检查 {time}",
  "settings.published_date": "发布时间 {date}",
  "settings.update_not_supported_hint": "此环境不支持更新。",
  "settings.update_desktop_only_hint": "更新仅在桌面应用中可用。",
  "settings.startup_title": "启动",
  "settings.mode_suffix": "模式",
  "settings.reset_startup_label": "重置默认启动模式",
  "settings.reset_startup_hint": "这将清除您保存的偏好设置，并在下次启动时显示模式选择。",
  "settings.advanced_title": "高级",
  "settings.advanced_hint": "重置 OpenWork 本地状态以重新测试入门流程。",
  "settings.reset_onboarding_label": "重置入门",
  "settings.reset_onboarding_hint": "清除 OpenCode 偏好设置并重启应用。",
  "settings.reset_app_data_label": "重置应用数据",
  "settings.reset_app_data_hint": "更激进的方式。清除 OpenCode 缓存 + 应用数据。",
  "settings.reset_requires_hint": "需要输入 RESET 并将重启应用。",
  "settings.developer_title": "开发者",
  "settings.opencode_cache_label": "OpenCode 缓存",
  "settings.opencode_cache_hint": "修复用于启动引擎的缓存数据。安全运行。",
  "settings.pending_permissions_label": "待处理的权限",
  "settings.recent_events_label": "最近的事件",
  "settings.stop_active_runs_hint": "停止活动运行以更新",
  "settings.stop_active_runs_reset_hint": "停止活动运行以重置",
  "settings.stop_runs_to_update": "停止活动运行以更新",
  "settings.stop_runs_to_reset": "停止活动运行以重置",
  "settings.updates_not_supported": "此环境不支持更新。",
  "settings.updates_desktop_only": "更新仅在桌面应用中可用。",

  // ==================== Reload ====================
  "reload.toast_title": "需要重新加载",
  "reload.toast_description": "你需要重新加载工作区才能使这些更改生效。",
  "reload.toast_warning": "重新加载可能会中断正在进行的会话或流程。",
  "reload.toast_warning_active": "检测到活动运行，立即重新加载将会中断它们。",
  "reload.toast_reload": "重新加载",
  "reload.toast_reloading": "正在重新加载...",
  "reload.toast_dismiss": "忽略",
  "reload.toast_blocked_host": "仅主机模式可重新加载。",
  "reload.toast_blocked_connect": "连接到此工作区后才能重新加载。",
  "reload.toast_blocked_runs": "请先停止活动运行再重新加载。",

  // ==================== Onboarding ====================
  "onboarding.starting_host": "正在启动 OpenWork...",
  "onboarding.searching_host": "正在搜索主机...",
  "onboarding.getting_ready": "准备就绪",
  "onboarding.verifying": "验证安全握手",
  "onboarding.create_first_workspace": "创建您的第一个工作区",
  "onboarding.create_workspace": "创建工作区",
  "onboarding.workspace_description": "选择文件夹和预设以设置您的工作区。",
  "onboarding.start": "启动 OpenWork",
  "onboarding.back": "返回",
  "onboarding.advanced_settings": "高级设置",
  "onboarding.opencode_engine": "OpenCode 引擎",
  "onboarding.refresh": "刷新",
  "onboarding.checking_cli": "正在检查 OpenCode CLI...",
  "onboarding.cli_not_found": "未找到 OpenCode CLI。",
  "onboarding.cli_needs_update": "OpenCode CLI 需要更新以支持 serve。",
  "onboarding.opencode": "OpenCode",
  "onboarding.cli_ready": "OpenCode CLI 就绪。",
  "onboarding.cli_version": "OpenCode {version}",
  "onboarding.windows_install_instruction": "安装 Windows 版 OpenCode，然后重启 OpenWork。确保 opencode.exe 在 PATH 中。",
  "onboarding.install_instruction": "安装 OpenCode 以启用主机模式（无需终端）。",
  "onboarding.install": "安装 OpenCode",
  "onboarding.recheck": "重新检查",
  "onboarding.ready_message": "OpenCode 已准备好以主机模式启动。",
  "onboarding.resolved_path": "解析路径",
  "onboarding.version": "版本",
  "onboarding.search_notes": "搜索说明",
  "onboarding.serve_help": "serve --help 输出",
  "onboarding.workspace_folder_label": "工作区是一个包含自己的 skills、插件和命令的文件夹。",
  "onboarding.theme_label": "主题",
  "onboarding.theme_current": "当前：{mode}",
  "onboarding.theme_system": "系统",
  "onboarding.theme_light": "浅色",
  "onboarding.theme_dark": "深色",
  "onboarding.access_label": "访问权限",
  "onboarding.folders_allowed": "{count} 个文件夹{plural}已授权",
  "onboarding.manage_access_hint": "您可以在高级设置中管理访问权限。",
  "onboarding.open_settings_hint": "需要引擎或访问选项？打开设置。",
  "onboarding.open_settings": "打开设置",
  "onboarding.add_folder_path": "添加文件夹路径",
  "onboarding.pick": "选择",
  "onboarding.add": "添加",
  "onboarding.remove": "移除",
  "onboarding.cli_label": "OpenCode CLI",
  "onboarding.cli_checking": "正在检查安装...",
  "onboarding.cli_not_found_hint": "未找到。请安装以运行主机模式。",
  "onboarding.cli_version_installed": "已安装",
  "onboarding.cli_recheck": "重新检查",
  "onboarding.cli_install_commands": "使用以下命令之一安装 OpenCode，然后重启 OpenWork。",
  "onboarding.show_search_notes": "显示搜索说明",
  "onboarding.last_checked": "上次检查时间 {time}",
  "onboarding.server_url_placeholder": "http://localhost:8088",
  "onboarding.directory_placeholder": "my-project",
  "onboarding.connect_host": "连接到主机",
  "onboarding.connect_description": "与现有的 OpenCode 服务器配对（局域网或隧道）。",
  "onboarding.server_url": "服务器 URL",
  "onboarding.directory": "目录（可选）",
  "onboarding.directory_hint": "如果主机运行多个工作区，请使用此选项。",
  "onboarding.connect": "连接",
  "onboarding.remote_workspace_title": "连接 OpenCode（推荐）",
  "onboarding.remote_workspace_description": "OpenWork 默认直接连接。需要跨设备时使用 OpenWork 主机。",
  "onboarding.remote_workspace_action": "连接",
  "onboarding.remote_workspace_card_title": "连接远程工作区",
  "onboarding.remote_workspace_card_description": "直接连接 OpenCode，或配对 OpenWork 主机。",
  "onboarding.advanced_openwork_host": "OpenWork 主机（Alpha）",
  "onboarding.advanced_openwork_hint": "使用主机地址和客户端令牌进行共享访问。",
  "onboarding.advanced_opencode_direct": "高级：OpenCode 直连",
  "onboarding.advanced_opencode_hint": "当没有主机时，直接连接 OpenCode 引擎。",
  "onboarding.welcome_title": "今天想如何运行 OpenWork？",
  "onboarding.run_local": "在此计算机上运行",
  "onboarding.run_local_description": "OpenWork 在本地运行 OpenCode 并保持您的工作私密。",
  "onboarding.engine_running": "引擎已在运行",
  "onboarding.attach_description": "附加到此设备上的现有会话。",
  "onboarding.attach": "附加",
  "onboarding.remember_choice": "记住我的选择",
  "onboarding.client_mode": "作为客户端连接（远程配对）",
  "onboarding.default_workspace_path": "~/OpenWork/Workspace",
  "onboarding.authorize_folder": "授权文件夹",
  "onboarding.choose_workspace_folder": "选择工作区文件夹",

  // ==================== Common ====================
  "common.alpha": "测试版",
  "common.change": "更改",
  "common.refresh": "刷新",
  "common.new": "新建",
  "common.install": "安装",
  "common.delete": "删除",
  "common.edit": "编辑",
  "common.save": "保存",
  "common.cancel": "取消",
  "common.close": "关闭",
  "common.open": "打开",
  "common.show": "显示",
  "common.hide": "隐藏",
  "common.path": "路径",
  "common.choose": "选择",
  "common.retry": "重试",
  "common.untitled": "未命名",
  "common.default_parens": "(默认)",
  "common.on": "开启",
  "common.off": "关闭",

  // ==================== Status ====================
  "status.connected": "已连接",
  "status.disconnected": "已断开",
  "status.idle": "空闲",
  "status.busy": "忙碌",
  "status.running": "运行中",
  "status.live": "实时",
  "status.connecting": "正在连接",
  "status.creating_workspace": "正在创建工作区",
  "status.deleting_command": "正在删除命令",
  "status.saving_workspace_command": "正在保存工作区命令",
  "status.saving_command": "正在保存命令",
  "status.loading_session": "正在加载会话",
  "status.creating_task": "正在创建新任务",
  "status.starting_engine": "正在启动引擎",
  "status.reloading_engine": "正在重新加载引擎",
  "status.restarting_engine": "正在重启引擎",
  "status.installing_opencode": "正在安装 OpenCode",
  "status.disconnecting": "正在断开连接",

  // ==================== Workspace Switching ====================
  "workspace.switching_title": "正在打开 {name}",
  "workspace.switching_title_unknown": "正在打开工作区",
  "workspace.switching_subtitle": "马上带你回到最近的工作。",
  "workspace.switching_status_preparing": "正在准备",
  "workspace.switching_status_connecting": "正在检查连接",
  "workspace.switching_status_loading": "正在加载最近任务",
  "workspace.switching_status_almost": "快好了",

  "app.connection_lost": "服务器连接已断开。请重新加载。",
  "app.unknown_error": "未知错误",
  "app.error.tauri_required": "此操作需要 Tauri 应用运行时。",
  "app.error.choose_folder": "选择一个文件夹以继续。",
  "app.error.pick_workspace_folder": "请先选择一个工作区文件夹。",
  "app.error.remote_base_url_required": "请先填写服务器地址。",
  "app.error.host_requires_local": "请先选择本地工作区以启动引擎。",
  "app.error.sidecar_unsupported_windows": "Windows 上的 Sidecar OpenCode 可用时会内置。将回退到 PATH。",
  "app.error.install_failed": "OpenCode 安装失败。请查看上方日志。",
  "app.error.command_name_template_required": "命令名称和指令为必填项。",
  "app.error.workspace_commands_desktop": "命令需要桌面应用。",
  "app.error.command_scope_unknown": "此命令无法在当前模式下管理。",
} as const;



================================================
FILE: packages/app/src/styles/tailwind-colors.ts
================================================
export const tailwindSafelist = {
  pattern:
        /^(bg|text|border)-(bronze|gold|brown|orange|tomato|red|ruby|crimson|pink|plum|purple|violet|iris|indigo|blue|cyan|teal|jade|green|grass|gray|mauve|slate|sage|olive|sand|sky|mint|lime|yellow|amber)-([1-9]|10|11|12)$/,
}

// Radix UI Colors
export const radixColors = {
  bronze: {
    1: 'var(--bronze-1)',
    2: 'var(--bronze-2)',
    3: 'var(--bronze-3)',
    4: 'var(--bronze-4)',
    5: 'var(--bronze-5)',
    6: 'var(--bronze-6)',
    7: 'var(--bronze-7)',
    8: 'var(--bronze-8)',
    9: 'var(--bronze-9)',
    10: 'var(--bronze-10)',
    11: 'var(--bronze-11)',
    12: 'var(--bronze-12)',
    a1: 'var(--bronze-a1)',
    a2: 'var(--bronze-a2)',
    a3: 'var(--bronze-a3)',
    a4: 'var(--bronze-a4)',
    a5: 'var(--bronze-a5)',
    a6: 'var(--bronze-a6)',
    a7: 'var(--bronze-a7)',
    a8: 'var(--bronze-a8)',
    a9: 'var(--bronze-a9)',
    a10: 'var(--bronze-a10)',
    a11: 'var(--bronze-a11)',
    a12: 'var(--bronze-a12)',
  },
  gold: {
    1: 'var(--gold-1)',
    2: 'var(--gold-2)',
    3: 'var(--gold-3)',
    4: 'var(--gold-4)',
    5: 'var(--gold-5)',
    6: 'var(--gold-6)',
    7: 'var(--gold-7)',
    8: 'var(--gold-8)',
    9: 'var(--gold-9)',
    10: 'var(--gold-10)',
    11: 'var(--gold-11)',
    12: 'var(--gold-12)',
    a1: 'var(--gold-a1)',
    a2: 'var(--gold-a2)',
    a3: 'var(--gold-a3)',
    a4: 'var(--gold-a4)',
    a5: 'var(--gold-a5)',
    a6: 'var(--gold-a6)',
    a7: 'var(--gold-a7)',
    a8: 'var(--gold-a8)',
    a9: 'var(--gold-a9)',
    a10: 'var(--gold-a10)',
    a11: 'var(--gold-a11)',
    a12: 'var(--gold-a12)',
  },
  brown: {
    1: 'var(--brown-1)',
    2: 'var(--brown-2)',
    3: 'var(--brown-3)',
    4: 'var(--brown-4)',
    5: 'var(--brown-5)',
    6: 'var(--brown-6)',
    7: 'var(--brown-7)',
    8: 'var(--brown-8)',
    9: 'var(--brown-9)',
    10: 'var(--brown-10)',
    11: 'var(--brown-11)',
    12: 'var(--brown-12)',
    a1: 'var(--brown-a1)',
    a2: 'var(--brown-a2)',
    a3: 'var(--brown-a3)',
    a4: 'var(--brown-a4)',
    a5: 'var(--brown-a5)',
    a6: 'var(--brown-a6)',
    a7: 'var(--brown-a7)',
    a8: 'var(--brown-a8)',
    a9: 'var(--brown-a9)',
    a10: 'var(--brown-a10)',
    a11: 'var(--brown-a11)',
    a12: 'var(--brown-a12)',
  },
  orange: {
    1: 'var(--orange-1)',
    2: 'var(--orange-2)',
    3: 'var(--orange-3)',
    4: 'var(--orange-4)',
    5: 'var(--orange-5)',
    6: 'var(--orange-6)',
    7: 'var(--orange-7)',
    8: 'var(--orange-8)',
    9: 'var(--orange-9)',
    10: 'var(--orange-10)',
    11: 'var(--orange-11)',
    12: 'var(--orange-12)',
    a1: 'var(--orange-a1)',
    a2: 'var(--orange-a2)',
    a3: 'var(--orange-a3)',
    a4: 'var(--orange-a4)',
    a5: 'var(--orange-a5)',
    a6: 'var(--orange-a6)',
    a7: 'var(--orange-a7)',
    a8: 'var(--orange-a8)',
    a9: 'var(--orange-a9)',
    a10: 'var(--orange-a10)',
    a11: 'var(--orange-a11)',
    a12: 'var(--orange-a12)',
  },
  tomato: {
    1: 'var(--tomato-1)',
    2: 'var(--tomato-2)',
    3: 'var(--tomato-3)',
    4: 'var(--tomato-4)',
    5: 'var(--tomato-5)',
    6: 'var(--tomato-6)',
    7: 'var(--tomato-7)',
    8: 'var(--tomato-8)',
    9: 'var(--tomato-9)',
    10: 'var(--tomato-10)',
    11: 'var(--tomato-11)',
    12: 'var(--tomato-12)',
    a1: 'var(--tomato-a1)',
    a2: 'var(--tomato-a2)',
    a3: 'var(--tomato-a3)',
    a4: 'var(--tomato-a4)',
    a5: 'var(--tomato-a5)',
    a6: 'var(--tomato-a6)',
    a7: 'var(--tomato-a7)',
    a8: 'var(--tomato-a8)',
    a9: 'var(--tomato-a9)',
    a10: 'var(--tomato-a10)',
    a11: 'var(--tomato-a11)',
    a12: 'var(--tomato-a12)',
  },
  red: {
    1: 'var(--red-1)',
    2: 'var(--red-2)',
    3: 'var(--red-3)',
    4: 'var(--red-4)',
    5: 'var(--red-5)',
    6: 'var(--red-6)',
    7: 'var(--red-7)',
    8: 'var(--red-8)',
    9: 'var(--red-9)',
    10: 'var(--red-10)',
    11: 'var(--red-11)',
    12: 'var(--red-12)',
    a1: 'var(--red-a1)',
    a2: 'var(--red-a2)',
    a3: 'var(--red-a3)',
    a4: 'var(--red-a4)',
    a5: 'var(--red-a5)',
    a6: 'var(--red-a6)',
    a7: 'var(--red-a7)',
    a8: 'var(--red-a8)',
    a9: 'var(--red-a9)',
    a10: 'var(--red-a10)',
    a11: 'var(--red-a11)',
    a12: 'var(--red-a12)',
  },
  ruby: {
    1: 'var(--ruby-1)',
    2: 'var(--ruby-2)',
    3: 'var(--ruby-3)',
    4: 'var(--ruby-4)',
    5: 'var(--ruby-5)',
    6: 'var(--ruby-6)',
    7: 'var(--ruby-7)',
    8: 'var(--ruby-8)',
    9: 'var(--ruby-9)',
    10: 'var(--ruby-10)',
    11: 'var(--ruby-11)',
    12: 'var(--ruby-12)',
    a1: 'var(--ruby-a1)',
    a2: 'var(--ruby-a2)',
    a3: 'var(--ruby-a3)',
    a4: 'var(--ruby-a4)',
    a5: 'var(--ruby-a5)',
    a6: 'var(--ruby-a6)',
    a7: 'var(--ruby-a7)',
    a8: 'var(--ruby-a8)',
    a9: 'var(--ruby-a9)',
    a10: 'var(--ruby-a10)',
    a11: 'var(--ruby-a11)',
    a12: 'var(--ruby-a12)',
  },
  crimson: {
    1: 'var(--crimson-1)',
    2: 'var(--crimson-2)',
    3: 'var(--crimson-3)',
    4: 'var(--crimson-4)',
    5: 'var(--crimson-5)',
    6: 'var(--crimson-6)',
    7: 'var(--crimson-7)',
    8: 'var(--crimson-8)',
    9: 'var(--crimson-9)',
    10: 'var(--crimson-10)',
    11: 'var(--crimson-11)',
    12: 'var(--crimson-12)',
    a1: 'var(--crimson-a1)',
    a2: 'var(--crimson-a2)',
    a3: 'var(--crimson-a3)',
    a4: 'var(--crimson-a4)',
    a5: 'var(--crimson-a5)',
    a6: 'var(--crimson-a6)',
    a7: 'var(--crimson-a7)',
    a8: 'var(--crimson-a8)',
    a9: 'var(--crimson-a9)',
    a10: 'var(--crimson-a10)',
    a11: 'var(--crimson-a11)',
    a12: 'var(--crimson-a12)',
  },
  pink: {
    1: 'var(--pink-1)',
    2: 'var(--pink-2)',
    3: 'var(--pink-3)',
    4: 'var(--pink-4)',
    5: 'var(--pink-5)',
    6: 'var(--pink-6)',
    7: 'var(--pink-7)',
    8: 'var(--pink-8)',
    9: 'var(--pink-9)',
    10: 'var(--pink-10)',
    11: 'var(--pink-11)',
    12: 'var(--pink-12)',
    a1: 'var(--pink-a1)',
    a2: 'var(--pink-a2)',
    a3: 'var(--pink-a3)',
    a4: 'var(--pink-a4)',
    a5: 'var(--pink-a5)',
    a6: 'var(--pink-a6)',
    a7: 'var(--pink-a7)',
    a8: 'var(--pink-a8)',
    a9: 'var(--pink-a9)',
    a10: 'var(--pink-a10)',
    a11: 'var(--pink-a11)',
    a12: 'var(--pink-a12)',
  },
  plum: {
    1: 'var(--plum-1)',
    2: 'var(--plum-2)',
    3: 'var(--plum-3)',
    4: 'var(--plum-4)',
    5: 'var(--plum-5)',
    6: 'var(--plum-6)',
    7: 'var(--plum-7)',
    8: 'var(--plum-8)',
    9: 'var(--plum-9)',
    10: 'var(--plum-10)',
    11: 'var(--plum-11)',
    12: 'var(--plum-12)',
    a1: 'var(--plum-a1)',
    a2: 'var(--plum-a2)',
    a3: 'var(--plum-a3)',
    a4: 'var(--plum-a4)',
    a5: 'var(--plum-a5)',
    a6: 'var(--plum-a6)',
    a7: 'var(--plum-a7)',
    a8: 'var(--plum-a8)',
    a9: 'var(--plum-a9)',
    a10: 'var(--plum-a10)',
    a11: 'var(--plum-a11)',
    a12: 'var(--plum-a12)',
  },
  purple: {
    1: 'var(--purple-1)',
    2: 'var(--purple-2)',
    3: 'var(--purple-3)',
    4: 'var(--purple-4)',
    5: 'var(--purple-5)',
    6: 'var(--purple-6)',
    7: 'var(--purple-7)',
    8: 'var(--purple-8)',
    9: 'var(--purple-9)',
    10: 'var(--purple-10)',
    11: 'var(--purple-11)',
    12: 'var(--purple-12)',
    a1: 'var(--purple-a1)',
    a2: 'var(--purple-a2)',
    a3: 'var(--purple-a3)',
    a4: 'var(--purple-a4)',
    a5: 'var(--purple-a5)',
    a6: 'var(--purple-a6)',
    a7: 'var(--purple-a7)',
    a8: 'var(--purple-a8)',
    a9: 'var(--purple-a9)',
    a10: 'var(--purple-a10)',
    a11: 'var(--purple-a11)',
    a12: 'var(--purple-a12)',
  },
  violet: {
    1: 'var(--violet-1)',
    2: 'var(--violet-2)',
    3: 'var(--violet-3)',
    4: 'var(--violet-4)',
    5: 'var(--violet-5)',
    6: 'var(--violet-6)',
    7: 'var(--violet-7)',
    8: 'var(--violet-8)',
    9: 'var(--violet-9)',
    10: 'var(--violet-10)',
    11: 'var(--violet-11)',
    12: 'var(--violet-12)',
    a1: 'var(--violet-a1)',
    a2: 'var(--violet-a2)',
    a3: 'var(--violet-a3)',
    a4: 'var(--violet-a4)',
    a5: 'var(--violet-a5)',
    a6: 'var(--violet-a6)',
    a7: 'var(--violet-a7)',
    a8: 'var(--violet-a8)',
    a9: 'var(--violet-a9)',
    a10: 'var(--violet-a10)',
    a11: 'var(--violet-a11)',
    a12: 'var(--violet-a12)',
  },
  iris: {
    1: 'var(--iris-1)',
    2: 'var(--iris-2)',
    3: 'var(--iris-3)',
    4: 'var(--iris-4)',
    5: 'var(--iris-5)',
    6: 'var(--iris-6)',
    7: 'var(--iris-7)',
    8: 'var(--iris-8)',
    9: 'var(--iris-9)',
    10: 'var(--iris-10)',
    11: 'var(--iris-11)',
    12: 'var(--iris-12)',
    a1: 'var(--iris-a1)',
    a2: 'var(--iris-a2)',
    a3: 'var(--iris-a3)',
    a4: 'var(--iris-a4)',
    a5: 'var(--iris-a5)',
    a6: 'var(--iris-a6)',
    a7: 'var(--iris-a7)',
    a8: 'var(--iris-a8)',
    a9: 'var(--iris-a9)',
    a10: 'var(--iris-a10)',
    a11: 'var(--iris-a11)',
    a12: 'var(--iris-a12)',
  },
  indigo: {
    1: 'var(--indigo-1)',
    2: 'var(--indigo-2)',
    3: 'var(--indigo-3)',
    4: 'var(--indigo-4)',
    5: 'var(--indigo-5)',
    6: 'var(--indigo-6)',
    7: 'var(--indigo-7)',
    8: 'var(--indigo-8)',
    9: 'var(--indigo-9)',
    10: 'var(--indigo-10)',
    11: 'var(--indigo-11)',
    12: 'var(--indigo-12)',
    a1: 'var(--indigo-a1)',
    a2: 'var(--indigo-a2)',
    a3: 'var(--indigo-a3)',
    a4: 'var(--indigo-a4)',
    a5: 'var(--indigo-a5)',
    a6: 'var(--indigo-a6)',
    a7: 'var(--indigo-a7)',
    a8: 'var(--indigo-a8)',
    a9: 'var(--indigo-a9)',
    a10: 'var(--indigo-a10)',
    a11: 'var(--indigo-a11)',
    a12: 'var(--indigo-a12)',
  },
  blue: {
    1: 'var(--blue-1)',
    2: 'var(--blue-2)',
    3: 'var(--blue-3)',
    4: 'var(--blue-4)',
    5: 'var(--blue-5)',
    6: 'var(--blue-6)',
    7: 'var(--blue-7)',
    8: 'var(--blue-8)',
    9: 'var(--blue-9)',
    10: 'var(--blue-10)',
    11: 'var(--blue-11)',
    12: 'var(--blue-12)',
    a1: 'var(--blue-a1)',
    a2: 'var(--blue-a2)',
    a3: 'var(--blue-a3)',
    a4: 'var(--blue-a4)',
    a5: 'var(--blue-a5)',
    a6: 'var(--blue-a6)',
    a7: 'var(--blue-a7)',
    a8: 'var(--blue-a8)',
    a9: 'var(--blue-a9)',
    a10: 'var(--blue-a10)',
    a11: 'var(--blue-a11)',
    a12: 'var(--blue-a12)',
  },
  cyan: {
    1: 'var(--cyan-1)',
    2: 'var(--cyan-2)',
    3: 'var(--cyan-3)',
    4: 'var(--cyan-4)',
    5: 'var(--cyan-5)',
    6: 'var(--cyan-6)',
    7: 'var(--cyan-7)',
    8: 'var(--cyan-8)',
    9: 'var(--cyan-9)',
    10: 'var(--cyan-10)',
    11: 'var(--cyan-11)',
    12: 'var(--cyan-12)',
    a1: 'var(--cyan-a1)',
    a2: 'var(--cyan-a2)',
    a3: 'var(--cyan-a3)',
    a4: 'var(--cyan-a4)',
    a5: 'var(--cyan-a5)',
    a6: 'var(--cyan-a6)',
    a7: 'var(--cyan-a7)',
    a8: 'var(--cyan-a8)',
    a9: 'var(--cyan-a9)',
    a10: 'var(--cyan-a10)',
    a11: 'var(--cyan-a11)',
    a12: 'var(--cyan-a12)',
  },
  teal: {
    1: 'var(--teal-1)',
    2: 'var(--teal-2)',
    3: 'var(--teal-3)',
    4: 'var(--teal-4)',
    5: 'var(--teal-5)',
    6: 'var(--teal-6)',
    7: 'var(--teal-7)',
    8: 'var(--teal-8)',
    9: 'var(--teal-9)',
    10: 'var(--teal-10)',
    11: 'var(--teal-11)',
    12: 'var(--teal-12)',
    a1: 'var(--teal-a1)',
    a2: 'var(--teal-a2)',
    a3: 'var(--teal-a3)',
    a4: 'var(--teal-a4)',
    a5: 'var(--teal-a5)',
    a6: 'var(--teal-a6)',
    a7: 'var(--teal-a7)',
    a8: 'var(--teal-a8)',
    a9: 'var(--teal-a9)',
    a10: 'var(--teal-a10)',
    a11: 'var(--teal-a11)',
    a12: 'var(--teal-a12)',
  },
  jade: {
    1: 'var(--jade-1)',
    2: 'var(--jade-2)',
    3: 'var(--jade-3)',
    4: 'var(--jade-4)',
    5: 'var(--jade-5)',
    6: 'var(--jade-6)',
    7: 'var(--jade-7)',
    8: 'var(--jade-8)',
    9: 'var(--jade-9)',
    10: 'var(--jade-10)',
    11: 'var(--jade-11)',
    12: 'var(--jade-12)',
    a1: 'var(--jade-a1)',
    a2: 'var(--jade-a2)',
    a3: 'var(--jade-a3)',
    a4: 'var(--jade-a4)',
    a5: 'var(--jade-a5)',
    a6: 'var(--jade-a6)',
    a7: 'var(--jade-a7)',
    a8: 'var(--jade-a8)',
    a9: 'var(--jade-a9)',
    a10: 'var(--jade-a10)',
    a11: 'var(--jade-a11)',
    a12: 'var(--jade-a12)',
  },
  green: {
    1: 'var(--green-1)',
    2: 'var(--green-2)',
    3: 'var(--green-3)',
    4: 'var(--green-4)',
    5: 'var(--green-5)',
    6: 'var(--green-6)',
    7: 'var(--green-7)',
    8: 'var(--green-8)',
    9: 'var(--green-9)',
    10: 'var(--green-10)',
    11: 'var(--green-11)',
    12: 'var(--green-12)',
    a1: 'var(--green-a1)',
    a2: 'var(--green-a2)',
    a3: 'var(--green-a3)',
    a4: 'var(--green-a4)',
    a5: 'var(--green-a5)',
    a6: 'var(--green-a6)',
    a7: 'var(--green-a7)',
    a8: 'var(--green-a8)',
    a9: 'var(--green-a9)',
    a10: 'var(--green-a10)',
    a11: 'var(--green-a11)',
    a12: 'var(--green-a12)',
  },
  grass: {
    1: 'var(--grass-1)',
    2: 'var(--grass-2)',
    3: 'var(--grass-3)',
    4: 'var(--grass-4)',
    5: 'var(--grass-5)',
    6: 'var(--grass-6)',
    7: 'var(--grass-7)',
    8: 'var(--grass-8)',
    9: 'var(--grass-9)',
    10: 'var(--grass-10)',
    11: 'var(--grass-11)',
    12: 'var(--grass-12)',
    a1: 'var(--grass-a1)',
    a2: 'var(--grass-a2)',
    a3: 'var(--grass-a3)',
    a4: 'var(--grass-a4)',
    a5: 'var(--grass-a5)',
    a6: 'var(--grass-a6)',
    a7: 'var(--grass-a7)',
    a8: 'var(--grass-a8)',
    a9: 'var(--grass-a9)',
    a10: 'var(--grass-a10)',
    a11: 'var(--grass-a11)',
    a12: 'var(--grass-a12)',
  },
  sky: {
    1: 'var(--sky-1)',
    2: 'var(--sky-2)',
    3: 'var(--sky-3)',
    4: 'var(--sky-4)',
    5: 'var(--sky-5)',
    6: 'var(--sky-6)',
    7: 'var(--sky-7)',
    8: 'var(--sky-8)',
    9: 'var(--sky-9)',
    10: 'var(--sky-10)',
    11: 'var(--sky-11)',
    12: 'var(--sky-12)',
    a1: 'var(--sky-a1)',
    a2: 'var(--sky-a2)',
    a3: 'var(--sky-a3)',
    a4: 'var(--sky-a4)',
    a5: 'var(--sky-a5)',
    a6: 'var(--sky-a6)',
    a7: 'var(--sky-a7)',
    a8: 'var(--sky-a8)',
    a9: 'var(--sky-a9)',
    a10: 'var(--sky-a10)',
    a11: 'var(--sky-a11)',
    a12: 'var(--sky-a12)',
  },
  mint: {
    1: 'var(--mint-1)',
    2: 'var(--mint-2)',
    3: 'var(--mint-3)',
    4: 'var(--mint-4)',
    5: 'var(--mint-5)',
    6: 'var(--mint-6)',
    7: 'var(--mint-7)',
    8: 'var(--mint-8)',
    9: 'var(--mint-9)',
    10: 'var(--mint-10)',
    11: 'var(--mint-11)',
    12: 'var(--mint-12)',
    a1: 'var(--mint-a1)',
    a2: 'var(--mint-a2)',
    a3: 'var(--mint-a3)',
    a4: 'var(--mint-a4)',
    a5: 'var(--mint-a5)',
    a6: 'var(--mint-a6)',
    a7: 'var(--mint-a7)',
    a8: 'var(--mint-a8)',
    a9: 'var(--mint-a9)',
    a10: 'var(--mint-a10)',
    a11: 'var(--mint-a11)',
    a12: 'var(--mint-a12)',
  },
  lime: {
    1: 'var(--lime-1)',
    2: 'var(--lime-2)',
    3: 'var(--lime-3)',
    4: 'var(--lime-4)',
    5: 'var(--lime-5)',
    6: 'var(--lime-6)',
    7: 'var(--lime-7)',
    8: 'var(--lime-8)',
    9: 'var(--lime-9)',
    10: 'var(--lime-10)',
    11: 'var(--lime-11)',
    12: 'var(--lime-12)',
    a1: 'var(--lime-a1)',
    a2: 'var(--lime-a2)',
    a3: 'var(--lime-a3)',
    a4: 'var(--lime-a4)',
    a5: 'var(--lime-a5)',
    a6: 'var(--lime-a6)',
    a7: 'var(--lime-a7)',
    a8: 'var(--lime-a8)',
    a9: 'var(--lime-a9)',
    a10: 'var(--lime-a10)',
    a11: 'var(--lime-a11)',
    a12: 'var(--lime-a12)',
  },
  yellow: {
    1: 'var(--yellow-1)',
    2: 'var(--yellow-2)',
    3: 'var(--yellow-3)',
    4: 'var(--yellow-4)',
    5: 'var(--yellow-5)',
    6: 'var(--yellow-6)',
    7: 'var(--yellow-7)',
    8: 'var(--yellow-8)',
    9: 'var(--yellow-9)',
    10: 'var(--yellow-10)',
    11: 'var(--yellow-11)',
    12: 'var(--yellow-12)',
    a1: 'var(--yellow-a1)',
    a2: 'var(--yellow-a2)',
    a3: 'var(--yellow-a3)',
    a4: 'var(--yellow-a4)',
    a5: 'var(--yellow-a5)',
    a6: 'var(--yellow-a6)',
    a7: 'var(--yellow-a7)',
    a8: 'var(--yellow-a8)',
    a9: 'var(--yellow-a9)',
    a10: 'var(--yellow-a10)',
    a11: 'var(--yellow-a11)',
    a12: 'var(--yellow-a12)',
  },
  amber: {
    1: 'var(--amber-1)',
    2: 'var(--amber-2)',
    3: 'var(--amber-3)',
    4: 'var(--amber-4)',
    5: 'var(--amber-5)',
    6: 'var(--amber-6)',
    7: 'var(--amber-7)',
    8: 'var(--amber-8)',
    9: 'var(--amber-9)',
    10: 'var(--amber-10)',
    11: 'var(--amber-11)',
    12: 'var(--amber-12)',
    a1: 'var(--amber-a1)',
    a2: 'var(--amber-a2)',
    a3: 'var(--amber-a3)',
    a4: 'var(--amber-a4)',
    a5: 'var(--amber-a5)',
    a6: 'var(--amber-a6)',
    a7: 'var(--amber-a7)',
    a8: 'var(--amber-a8)',
    a9: 'var(--amber-a9)',
    a10: 'var(--amber-a10)',
    a11: 'var(--amber-a11)',
    a12: 'var(--amber-a12)',
  },
  gray: {
    1: 'var(--gray-1)',
    2: 'var(--gray-2)',
    3: 'var(--gray-3)',
    4: 'var(--gray-4)',
    5: 'var(--gray-5)',
    6: 'var(--gray-6)',
    7: 'var(--gray-7)',
    8: 'var(--gray-8)',
    9: 'var(--gray-9)',
    10: 'var(--gray-10)',
    11: 'var(--gray-11)',
    12: 'var(--gray-12)',
    a1: 'var(--gray-a1)',
    a2: 'var(--gray-a2)',
    a3: 'var(--gray-a3)',
    a4: 'var(--gray-a4)',
    a5: 'var(--gray-a5)',
    a6: 'var(--gray-a6)',
    a7: 'var(--gray-a7)',
    a8: 'var(--gray-a8)',
    a9: 'var(--gray-a9)',
    a10: 'var(--gray-a10)',
    a11: 'var(--gray-a11)',
    a12: 'var(--gray-a12)',
  },
  mauve: {
    1: 'var(--mauve-1)',
    2: 'var(--mauve-2)',
    3: 'var(--mauve-3)',
    4: 'var(--mauve-4)',
    5: 'var(--mauve-5)',
    6: 'var(--mauve-6)',
    7: 'var(--mauve-7)',
    8: 'var(--mauve-8)',
    9: 'var(--mauve-9)',
    10: 'var(--mauve-10)',
    11: 'var(--mauve-11)',
    12: 'var(--mauve-12)',
    a1: 'var(--mauve-a1)',
    a2: 'var(--mauve-a2)',
    a3: 'var(--mauve-a3)',
    a4: 'var(--mauve-a4)',
    a5: 'var(--mauve-a5)',
    a6: 'var(--mauve-a6)',
    a7: 'var(--mauve-a7)',
    a8: 'var(--mauve-a8)',
    a9: 'var(--mauve-a9)',
    a10: 'var(--mauve-a10)',
    a11: 'var(--mauve-a11)',
    a12: 'var(--mauve-a12)',
  },
  slate: {
    1: 'var(--slate-1)',
    2: 'var(--slate-2)',
    3: 'var(--slate-3)',
    4: 'var(--slate-4)',
    5: 'var(--slate-5)',
    6: 'var(--slate-6)',
    7: 'var(--slate-7)',
    8: 'var(--slate-8)',
    9: 'var(--slate-9)',
    10: 'var(--slate-10)',
    11: 'var(--slate-11)',
    12: 'var(--slate-12)',
    a1: 'var(--slate-a1)',
    a2: 'var(--slate-a2)',
    a3: 'var(--slate-a3)',
    a4: 'var(--slate-a4)',
    a5: 'var(--slate-a5)',
    a6: 'var(--slate-a6)',
    a7: 'var(--slate-a7)',
    a8: 'var(--slate-a8)',
    a9: 'var(--slate-a9)',
    a10: 'var(--slate-a10)',
    a11: 'var(--slate-a11)',
    a12: 'var(--slate-a12)',
  },
  sage: {
    1: 'var(--sage-1)',
    2: 'var(--sage-2)',
    3: 'var(--sage-3)',
    4: 'var(--sage-4)',
    5: 'var(--sage-5)',
    6: 'var(--sage-6)',
    7: 'var(--sage-7)',
    8: 'var(--sage-8)',
    9: 'var(--sage-9)',
    10: 'var(--sage-10)',
    11: 'var(--sage-11)',
    12: 'var(--sage-12)',
    a1: 'var(--sage-a1)',
    a2: 'var(--sage-a2)',
    a3: 'var(--sage-a3)',
    a4: 'var(--sage-a4)',
    a5: 'var(--sage-a5)',
    a6: 'var(--sage-a6)',
    a7: 'var(--sage-a7)',
    a8: 'var(--sage-a8)',
    a9: 'var(--sage-a9)',
    a10: 'var(--sage-a10)',
    a11: 'var(--sage-a11)',
    a12: 'var(--sage-a12)',
  },
  olive: {
    1: 'var(--olive-1)',
    2: 'var(--olive-2)',
    3: 'var(--olive-3)',
    4: 'var(--olive-4)',
    5: 'var(--olive-5)',
    6: 'var(--olive-6)',
    7: 'var(--olive-7)',
    8: 'var(--olive-8)',
    9: 'var(--olive-9)',
    10: 'var(--olive-10)',
    11: 'var(--olive-11)',
    12: 'var(--olive-12)',
    a1: 'var(--olive-a1)',
    a2: 'var(--olive-a2)',
    a3: 'var(--olive-a3)',
    a4: 'var(--olive-a4)',
    a5: 'var(--olive-a5)',
    a6: 'var(--olive-a6)',
    a7: 'var(--olive-a7)',
    a8: 'var(--olive-a8)',
    a9: 'var(--olive-a9)',
    a10: 'var(--olive-a10)',
    a11: 'var(--olive-a11)',
    a12: 'var(--olive-a12)',
  },
  sand: {
    1: 'var(--sand-1)',
    2: 'var(--sand-2)',
    3: 'var(--sand-3)',
    4: 'var(--sand-4)',
    5: 'var(--sand-5)',
    6: 'var(--sand-6)',
    7: 'var(--sand-7)',
    8: 'var(--sand-8)',
    9: 'var(--sand-9)',
    10: 'var(--sand-10)',
    11: 'var(--sand-11)',
    12: 'var(--sand-12)',
    a1: 'var(--sand-a1)',
    a2: 'var(--sand-a2)',
    a3: 'var(--sand-a3)',
    a4: 'var(--sand-a4)',
    a5: 'var(--sand-a5)',
    a6: 'var(--sand-a6)',
    a7: 'var(--sand-a7)',
    a8: 'var(--sand-a8)',
    a9: 'var(--sand-a9)',
    a10: 'var(--sand-a10)',
    a11: 'var(--sand-a11)',
    a12: 'var(--sand-a12)',
  },
}


================================================
FILE: packages/desktop/package.json
================================================
{
  "name": "@different-ai/openwork",
  "private": true,
  "version": "0.7.1",
  "type": "module",
  "scripts": {
    "dev": "tauri dev --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
    "build": "tauri build",
    "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^2.0.0"
  },
  "packageManager": "pnpm@10.27.0"
}



================================================
FILE: packages/desktop/scripts/prepare-sidecar.mjs
================================================
import { spawnSync } from "child_process";
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, statSync, unlinkSync } from "fs";
import { dirname, join, resolve } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";

const TARGET_TRIPLE = "x86_64-pc-windows-msvc";
const DOWNLOAD_URL =
  "https://github.com/anomalyco/opencode/releases/latest/download/opencode-windows-x64.zip";

const __dirname = dirname(fileURLToPath(import.meta.url));
const sidecarDir = join(__dirname, "..", "src-tauri", "sidecars");

// Target triple for native platform binaries
const resolvedTargetTriple = (() => {
  const envTarget =
    process.env.TAURI_ENV_TARGET_TRIPLE ??
    process.env.CARGO_CFG_TARGET_TRIPLE ??
    process.env.TARGET;
  if (envTarget) return envTarget;
  if (process.platform === "darwin") {
    return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
  }
  if (process.platform === "linux") {
    return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu";
  }
  if (process.platform === "win32") {
    return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
  }
  return null;
})();

const bunTarget = (() => {
  switch (resolvedTargetTriple) {
    case "aarch64-apple-darwin":
      return "bun-darwin-arm64";
    case "x86_64-apple-darwin":
      return "bun-darwin-x64";
    case "aarch64-unknown-linux-gnu":
      return "bun-linux-arm64";
    case "x86_64-unknown-linux-gnu":
      return "bun-linux-x64";
    case "x86_64-pc-windows-msvc":
      return "bun-windows-x64";
    default:
      return null;
  }
})();

// openwork-server paths
const openworkServerBaseName = "openwork-server";
const openworkServerName = process.platform === "win32" ? `${openworkServerBaseName}.exe` : openworkServerBaseName;
const openworkServerPath = join(sidecarDir, openworkServerName);
const openworkServerBuildName = bunTarget
  ? `${openworkServerBaseName}-${bunTarget}${bunTarget.includes("windows") ? ".exe" : ""}`
  : openworkServerName;
const openworkServerBuildPath = join(sidecarDir, openworkServerBuildName);
const openworkServerTargetTriple = resolvedTargetTriple;
const openworkServerTargetName = openworkServerTargetTriple
  ? `${openworkServerBaseName}-${openworkServerTargetTriple}${openworkServerTargetTriple.includes("windows") ? ".exe" : ""}`
  : null;
const openworkServerTargetPath = openworkServerTargetName ? join(sidecarDir, openworkServerTargetName) : null;

const openworkServerDir = resolve(__dirname, "..", "..", "server");

// owpenbot paths
const owpenbotBaseName = "owpenbot";
const owpenbotName = process.platform === "win32" ? `${owpenbotBaseName}.exe` : owpenbotBaseName;
const owpenbotPath = join(sidecarDir, owpenbotName);
const owpenbotBuildName = bunTarget
  ? `${owpenbotBaseName}-${bunTarget}${bunTarget.includes("windows") ? ".exe" : ""}`
  : owpenbotName;
const owpenbotBuildPath = join(sidecarDir, owpenbotBuildName);
const owpenbotTargetTriple = resolvedTargetTriple;
const owpenbotTargetName = owpenbotTargetTriple
  ? `${owpenbotBaseName}-${owpenbotTargetTriple}${owpenbotTargetTriple.includes("windows") ? ".exe" : ""}`
  : null;
const owpenbotTargetPath = owpenbotTargetName ? join(sidecarDir, owpenbotTargetName) : null;

const owpenbotDir = resolve(__dirname, "..", "..", "owpenbot");
const targetSidecarPath = join(sidecarDir, `opencode-${TARGET_TRIPLE}.exe`);
const devSidecarPath = join(sidecarDir, "opencode.exe");

const readHeader = (filePath, length = 256) => {
  const fd = openSync(filePath, "r");
  try {
    const buffer = Buffer.alloc(length);
    const bytesRead = readSync(fd, buffer, 0, length, 0);
    return buffer.subarray(0, bytesRead).toString("utf8");
  } finally {
    closeSync(fd);
  }
};

const isStubBinary = (filePath) => {
  try {
    const stat = statSync(filePath);
    if (!stat.isFile()) return true;
    if (stat.size < 1024) return true;
    const header = readHeader(filePath);
    if (header.startsWith("#!")) return true;
    if (header.includes("Sidecar missing") || header.includes("Bun is required")) return true;
  } catch {
    return true;
  }
  return false;
};

const shouldBuildOpenworkServer =
  !existsSync(openworkServerBuildPath) || isStubBinary(openworkServerBuildPath);

if (shouldBuildOpenworkServer) {
  mkdirSync(sidecarDir, { recursive: true });
  if (existsSync(openworkServerBuildPath)) {
    try {
      unlinkSync(openworkServerBuildPath);
    } catch {
      // ignore
    }
  }
  const openworkServerArgs = ["./script/build.ts", "--outdir", sidecarDir, "--filename", "openwork-server"];
  if (bunTarget) {
    openworkServerArgs.push("--target", bunTarget);
  }
  const buildResult = spawnSync("bun", openworkServerArgs, {
    cwd: openworkServerDir,
    stdio: "inherit",
  });

  if (buildResult.status !== 0) {
    process.exit(buildResult.status ?? 1);
  }
}

if (existsSync(openworkServerBuildPath)) {
  const shouldCopyCanonical = !existsSync(openworkServerPath) || isStubBinary(openworkServerPath);
  if (shouldCopyCanonical && openworkServerBuildPath !== openworkServerPath) {
    try {
      if (existsSync(openworkServerPath)) {
        unlinkSync(openworkServerPath);
      }
    } catch {
      // ignore
    }
    copyFileSync(openworkServerBuildPath, openworkServerPath);
  }

  if (openworkServerTargetPath) {
    const shouldCopyTarget = !existsSync(openworkServerTargetPath) || isStubBinary(openworkServerTargetPath);
    if (shouldCopyTarget && openworkServerBuildPath !== openworkServerTargetPath) {
      try {
        if (existsSync(openworkServerTargetPath)) {
          unlinkSync(openworkServerTargetPath);
        }
      } catch {
        // ignore
      }
      copyFileSync(openworkServerBuildPath, openworkServerTargetPath);
    }
  }
}

// Build owpenbot
const shouldBuildOwpenbot = !existsSync(owpenbotBuildPath) || isStubBinary(owpenbotBuildPath);

if (shouldBuildOwpenbot) {
  mkdirSync(sidecarDir, { recursive: true });
  if (existsSync(owpenbotBuildPath)) {
    try {
      unlinkSync(owpenbotBuildPath);
    } catch {
      // ignore
    }
  }
  const owpenbotArgs = ["./script/build.ts", "--outdir", sidecarDir, "--filename", "owpenbot"];
  if (bunTarget) {
    owpenbotArgs.push("--target", bunTarget);
  }
  const owpenbotBuildResult = spawnSync("bun", owpenbotArgs, {
    cwd: owpenbotDir,
    stdio: "inherit",
  });

  if (owpenbotBuildResult.status !== 0) {
    process.exit(owpenbotBuildResult.status ?? 1);
  }
}

if (existsSync(owpenbotBuildPath)) {
  const shouldCopyCanonical = !existsSync(owpenbotPath) || isStubBinary(owpenbotPath);
  if (shouldCopyCanonical && owpenbotBuildPath !== owpenbotPath) {
    try {
      if (existsSync(owpenbotPath)) {
        unlinkSync(owpenbotPath);
      }
    } catch {
      // ignore
    }
    copyFileSync(owpenbotBuildPath, owpenbotPath);
  }

  if (owpenbotTargetPath) {
    const shouldCopyOwpenbotTarget = !existsSync(owpenbotTargetPath) || isStubBinary(owpenbotTargetPath);
    if (shouldCopyOwpenbotTarget && owpenbotBuildPath !== owpenbotTargetPath) {
      try {
        if (existsSync(owpenbotTargetPath)) {
          unlinkSync(owpenbotTargetPath);
        }
      } catch {
        // ignore
      }
      copyFileSync(owpenbotBuildPath, owpenbotTargetPath);
    }
  }
}

if (process.platform !== "win32") {
  console.log("Skipping Windows sidecar download (non-Windows host).");
  process.exit(0);
}

if (existsSync(targetSidecarPath)) {
  console.log(`OpenCode sidecar already present: ${targetSidecarPath}`);
  process.exit(0);
}

mkdirSync(sidecarDir, { recursive: true });

const stamp = Date.now();
const zipPath = join(tmpdir(), `opencode-windows-x64-${stamp}.zip`);
const extractDir = join(tmpdir(), `opencode-windows-x64-${stamp}`);
const extractedExe = join(extractDir, "opencode.exe");

const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
const psScript = [
  "$ErrorActionPreference = 'Stop'",
  `Invoke-WebRequest -Uri ${psQuote(DOWNLOAD_URL)} -OutFile ${psQuote(zipPath)}`,
  `Expand-Archive -Path ${psQuote(zipPath)} -DestinationPath ${psQuote(extractDir)} -Force`,
  `if (!(Test-Path ${psQuote(extractedExe)})) { throw 'opencode.exe missing in archive' }`,
  `Copy-Item -Path ${psQuote(extractedExe)} -Destination ${psQuote(targetSidecarPath)} -Force`,
  `Copy-Item -Path ${psQuote(extractedExe)} -Destination ${psQuote(devSidecarPath)} -Force`,
].join("; ");

const result = spawnSync("powershell", ["-NoProfile", "-Command", psScript], {
  stdio: "inherit",
});

if (result.status !== 0) {
  process.exit(result.status ?? 1);
}



================================================
FILE: packages/desktop/src-tauri/build.rs
================================================
use std::env;
use std::fs;
use std::path::PathBuf;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

fn main() {
  ensure_opencode_sidecar();
  ensure_openwork_server_sidecar();
  ensure_owpenbot_sidecar();
  tauri_build::build();
}

fn ensure_opencode_sidecar() {
  let target = env::var("CARGO_CFG_TARGET_TRIPLE")
    .or_else(|_| env::var("TARGET"))
    .or_else(|_| env::var("TAURI_ENV_TARGET_TRIPLE"))
    .unwrap_or_default();
  if target.is_empty() {
    return;
  }

  let manifest_dir = env::var("CARGO_MANIFEST_DIR")
    .map(PathBuf::from)
    .unwrap_or_else(|_| PathBuf::from("."));
  let sidecar_dir = manifest_dir.join("sidecars");

  let canonical_name = if target.contains("windows") {
    "opencode.exe"
  } else {
    "opencode"
  };

  let mut target_name = format!("opencode-{target}");
  if target.contains("windows") {
    target_name.push_str(".exe");
  }

  let dest_path = sidecar_dir.join(canonical_name);
  let target_dest_path = sidecar_dir.join(target_name);

  let profile = env::var("PROFILE").unwrap_or_default();
  if !dest_path.exists() {
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
  }
  if !target_dest_path.exists() {
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
  }

  if dest_path.exists() && target_dest_path.exists() {
    return;
  }

  if target_dest_path.exists() && !dest_path.exists() {
    if copy_sidecar(&target_dest_path, &dest_path, &target) {
      return;
    }
  }

  let source_path = env::var("OPENCODE_BIN_PATH")
    .ok()
    .map(PathBuf::from)
    .filter(|path| path.is_file())
    .or_else(|| find_in_path(if target.contains("windows") { "opencode.exe" } else { "opencode" }));

  let Some(source_path) = source_path else {
    println!(
      "cargo:warning=OpenCode sidecar missing at {} (set OPENCODE_BIN_PATH or install OpenCode)",
      dest_path.display()
    );

    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
    return;
  };

  if fs::create_dir_all(&sidecar_dir).is_err() {
    return;
  }

  let copied = copy_sidecar(&source_path, &dest_path, &target);

  if copied {
    #[cfg(unix)]
    {
      let _ = fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o755));
    }
    let _ = copy_sidecar(&dest_path, &target_dest_path, &target);
  } else {
    println!(
      "cargo:warning=Failed to copy OpenCode sidecar from {} to {}",
      source_path.display(),
      dest_path.display()
    );
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
  }
}

fn ensure_openwork_server_sidecar() {
  let target = env::var("CARGO_CFG_TARGET_TRIPLE")
    .or_else(|_| env::var("TARGET"))
    .or_else(|_| env::var("TAURI_ENV_TARGET_TRIPLE"))
    .unwrap_or_default();
  if target.is_empty() {
    return;
  }

  let manifest_dir = env::var("CARGO_MANIFEST_DIR")
    .map(PathBuf::from)
    .unwrap_or_else(|_| PathBuf::from("."));
  let sidecar_dir = manifest_dir.join("sidecars");

  let canonical_name = if target.contains("windows") {
    "openwork-server.exe"
  } else {
    "openwork-server"
  };

  let mut target_name = format!("openwork-server-{target}");
  if target.contains("windows") {
    target_name.push_str(".exe");
  }

  let dest_path = sidecar_dir.join(canonical_name);
  let target_dest_path = sidecar_dir.join(target_name);

  if dest_path.exists() {
    return;
  }

  if target_dest_path.exists() {
    if copy_sidecar(&target_dest_path, &dest_path, &target) {
      return;
    }
  }

  let source_path = env::var("OPENWORK_SERVER_BIN_PATH")
    .ok()
    .map(PathBuf::from)
    .filter(|path| path.is_file())
    .or_else(|| {
      find_in_path(if target.contains("windows") {
        "openwork-server.exe"
      } else {
        "openwork-server"
      })
    });

  let profile = env::var("PROFILE").unwrap_or_default();

  let Some(source_path) = source_path else {
    println!(
      "cargo:warning=OpenWork server sidecar missing at {} (set OPENWORK_SERVER_BIN_PATH or install openwork-server)",
      dest_path.display()
    );

    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
    return;
  };

  if fs::create_dir_all(&sidecar_dir).is_err() {
    return;
  }

  let copied = copy_sidecar(&source_path, &dest_path, &target);

  if copied {
    #[cfg(unix)]
    {
      let _ = fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o755));
    }
    let _ = copy_sidecar(&dest_path, &target_dest_path, &target);
  } else {
    println!(
      "cargo:warning=Failed to copy OpenWork server sidecar from {} to {}",
      source_path.display(),
      dest_path.display()
    );
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
  }

  if !dest_path.exists() {
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
  }
  if !target_dest_path.exists() {
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
  }
}

fn ensure_owpenbot_sidecar() {
  let target = env::var("CARGO_CFG_TARGET_TRIPLE")
    .or_else(|_| env::var("TARGET"))
    .or_else(|_| env::var("TAURI_ENV_TARGET_TRIPLE"))
    .unwrap_or_default();
  if target.is_empty() {
    return;
  }

  let manifest_dir = env::var("CARGO_MANIFEST_DIR")
    .map(PathBuf::from)
    .unwrap_or_else(|_| PathBuf::from("."));
  let sidecar_dir = manifest_dir.join("sidecars");

  let canonical_name = if target.contains("windows") {
    "owpenbot.exe"
  } else {
    "owpenbot"
  };

  let mut target_name = format!("owpenbot-{target}");
  if target.contains("windows") {
    target_name.push_str(".exe");
  }

  let dest_path = sidecar_dir.join(canonical_name);
  let target_dest_path = sidecar_dir.join(target_name);

  if dest_path.exists() {
    return;
  }

  if target_dest_path.exists() {
    if copy_sidecar(&target_dest_path, &dest_path, &target) {
      return;
    }
  }

  let source_path = env::var("OWPENBOT_BIN_PATH")
    .ok()
    .map(PathBuf::from)
    .filter(|path| path.is_file())
    .or_else(|| {
      find_in_path(if target.contains("windows") {
        "owpenbot.exe"
      } else {
        "owpenbot"
      })
    });

  let profile = env::var("PROFILE").unwrap_or_default();

  let Some(source_path) = source_path else {
    println!(
      "cargo:warning=Owpenbot sidecar missing at {} (set OWPENBOT_BIN_PATH or install owpenbot)",
      dest_path.display()
    );

    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
    return;
  };

  if fs::create_dir_all(&sidecar_dir).is_err() {
    return;
  }

  let copied = copy_sidecar(&source_path, &dest_path, &target);

  if copied {
    #[cfg(unix)]
    {
      let _ = fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o755));
    }
    let _ = copy_sidecar(&dest_path, &target_dest_path, &target);
  } else {
    println!(
      "cargo:warning=Failed to copy Owpenbot sidecar from {} to {}",
      source_path.display(),
      dest_path.display()
    );
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
  }

  if !dest_path.exists() {
    create_debug_stub(&dest_path, &sidecar_dir, &profile, &target);
  }
  if !target_dest_path.exists() {
    create_debug_stub(&target_dest_path, &sidecar_dir, &profile, &target);
  }
}

fn copy_sidecar(source_path: &PathBuf, dest_path: &PathBuf, target: &str) -> bool {
  let mut copied = fs::copy(source_path, dest_path).is_ok();

  #[cfg(unix)]
  if !copied {
    if std::os::unix::fs::symlink(source_path, dest_path).is_ok() {
      copied = true;
    }
  }

  #[cfg(windows)]
  if !copied {
    if fs::hard_link(source_path, dest_path).is_ok() {
      copied = true;
    }
  }

  if copied {
    #[cfg(unix)]
    {
      let _ = fs::set_permissions(dest_path, fs::Permissions::from_mode(0o755));
    }
  } else if target.contains("windows") {
    let _ = fs::remove_file(dest_path);
  }

  copied
}

fn find_in_path(binary: &str) -> Option<PathBuf> {
  let paths = env::var_os("PATH")?;
  env::split_paths(&paths).find_map(|dir| {
    let candidate = dir.join(binary);
    if candidate.is_file() {
      Some(candidate)
    } else {
      None
    }
  })
}

fn create_debug_stub(dest_path: &PathBuf, sidecar_dir: &PathBuf, profile: &str, target: &str) {
  if profile == "release" || target.contains("windows") {
    return;
  }

  if fs::create_dir_all(sidecar_dir).is_err() {
    return;
  }

  let stub = "#!/usr/bin/env bash\n\
echo 'Sidecar missing. Install the binary or set the *_BIN_PATH env var.'\n\
exit 1\n";
  if fs::write(dest_path, stub).is_ok() {
    #[cfg(unix)]
    let _ = fs::set_permissions(dest_path, fs::Permissions::from_mode(0o755));
  }
}



================================================
FILE: packages/desktop/src-tauri/Cargo.toml
================================================
[package]
name = "openwork"
version = "0.7.1"
description = "OpenWork"
authors = ["Different AI"]
edition = "2021"

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
json5 = "0.4"
notify = "6.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png"] }
qrcode = { version = "0.14", default-features = false, features = ["image"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2.5.3"
tauri-plugin-shell = "2"
uuid = { version = "1", features = ["v4"] }
ureq = "2.10"
gethostname = "0.4"
local-ip-address = "0.5"
walkdir = "2.5"
zip = "0.6"

[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.9.0"



================================================
FILE: packages/desktop/src-tauri/entitlements.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.cs.allow-jit</key>
  <true/>
  <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
  <true/>
  <key>com.apple.security.cs.disable-executable-page-protection</key>
  <true/>
</dict>
</plist>



================================================
FILE: packages/desktop/src-tauri/tauri.conf.json
================================================
{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "OpenWork",
  "version": "0.7.1",
  "identifier": "com.differentai.openwork",
  "build": {
    "beforeDevCommand": "pnpm -C ../.. --filter @different-ai/openwork run prepare:sidecar && pnpm -w dev:ui",
    "beforeBuildCommand": "pnpm -C ../.. --filter @different-ai/openwork run prepare:sidecar && pnpm -w build:ui",
    "devUrl": "http://localhost:5173",
    "frontendDist": "../../app/dist"
  },
  "app": {
    "windows": [
      {
        "title": "OpenWork",
        "width": 1180,
        "height": 820,
        "resizable": true
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "createUpdaterArtifacts": true,
    "macOS": {
      "entitlements": "./entitlements.plist"
    },
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ],
    "externalBin": [
      "sidecars/opencode",
      "sidecars/openwork-server",
      "sidecars/owpenbot"
    ]
  },
  "plugins": {
    "updater": {
      "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDUwNDdCNTAwMTcxNUUxQTQKUldTazRSVVhBTFZIVUpNRi9OQnNRaElZY1NUWUQxS1ZJOTFUeUdPd2d6ZnFmMW5QV2xJQ1hXTTkK",
      "endpoints": [
        "https://github.com/different-ai/openwork/releases/latest/download/latest.json"
      ]
    }
  }
}



================================================
FILE: packages/desktop/src-tauri/capabilities/default.json
================================================
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "openwork-default",
  "description": "Default OpenWork capability (UI permissions)",
  "windows": ["*"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "dialog:allow-save",
    "updater:default",
    "process:default",
    "opener:allow-reveal-item-in-dir",
    "opener:allow-open-path",
    "opener:allow-open-url",
    "opener:allow-default-urls"
  ]
}



================================================
FILE: packages/desktop/src-tauri/gen/schemas/capabilities.json
================================================
{"openwork-default":{"identifier":"openwork-default","description":"Default OpenWork capability (UI permissions)","local":true,"windows":["*"],"permissions":["core:default","dialog:allow-open","dialog:allow-save","updater:default","process:default","opener:allow-reveal-item-in-dir","opener:allow-open-path","opener:allow-open-url","opener:allow-default-urls"]}}


================================================
FILE: packages/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
  <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
  <background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>


================================================
FILE: packages/desktop/src-tauri/icons/android/values/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <color name="ic_launcher_background">#fff</color>
</resources>


================================================
FILE: packages/desktop/src-tauri/src/config.rs
================================================
use std::env;
use std::fs;
use std::path::PathBuf;

use crate::types::{ExecResult, OpencodeConfigFile};

fn opencode_config_candidates(
    scope: &str,
    project_dir: &str,
) -> Result<(PathBuf, PathBuf), String> {
    match scope {
        "project" => {
            if project_dir.trim().is_empty() {
                return Err("projectDir is required".to_string());
            }
            let root = PathBuf::from(project_dir);
            Ok((root.join("opencode.jsonc"), root.join("opencode.json")))
        }
        "global" => {
            let base = if let Ok(dir) = env::var("XDG_CONFIG_HOME") {
                PathBuf::from(dir)
            } else if let Ok(home) = env::var("HOME") {
                PathBuf::from(home).join(".config")
            } else {
                return Err("Unable to resolve config directory".to_string());
            };

            let root = base.join("opencode");
            Ok((root.join("opencode.jsonc"), root.join("opencode.json")))
        }
        _ => Err("scope must be 'project' or 'global'".to_string()),
    }
}

pub fn resolve_opencode_config_path(scope: &str, project_dir: &str) -> Result<PathBuf, String> {
    let (jsonc_path, json_path) = opencode_config_candidates(scope, project_dir)?;

    if jsonc_path.exists() {
        return Ok(jsonc_path);
    }

    if json_path.exists() {
        return Ok(json_path);
    }

    Ok(jsonc_path)
}

pub fn read_opencode_config(scope: &str, project_dir: &str) -> Result<OpencodeConfigFile, String> {
    let path = resolve_opencode_config_path(scope.trim(), project_dir)?;
    let exists = path.exists();

    let content = if exists {
        Some(
            fs::read_to_string(&path)
                .map_err(|e| format!("Failed to read {}: {e}", path.display()))?,
        )
    } else {
        None
    };

    Ok(OpencodeConfigFile {
        path: path.to_string_lossy().to_string(),
        exists,
        content,
    })
}

pub fn write_opencode_config(
    scope: &str,
    project_dir: &str,
    content: &str,
) -> Result<ExecResult, String> {
    let path = resolve_opencode_config_path(scope.trim(), project_dir)?;

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create config dir {}: {e}", parent.display()))?;
    }

    fs::write(&path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Wrote {}", path.display()),
        stderr: String::new(),
    })
}



================================================
FILE: packages/desktop/src-tauri/src/fs.rs
================================================
use std::fs;
use std::path::Path;

pub fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {
    if !src.is_dir() {
        return Err(format!("Source is not a directory: {}", src.display()));
    }

    fs::create_dir_all(dest)
        .map_err(|e| format!("Failed to create dir {}: {e}", dest.display()))?;

    for entry in
        fs::read_dir(src).map_err(|e| format!("Failed to read dir {}: {e}", src.display()))?
    {
        let entry = entry.map_err(|e| e.to_string())?;
        let file_type = entry.file_type().map_err(|e| e.to_string())?;

        let from = entry.path();
        let to = dest.join(entry.file_name());

        if file_type.is_dir() {
            copy_dir_recursive(&from, &to)?;
            continue;
        }

        if file_type.is_file() {
            fs::copy(&from, &to).map_err(|e| {
                format!("Failed to copy {} -> {}: {e}", from.display(), to.display())
            })?;
            continue;
        }

        // Skip symlinks and other non-regular entries.
    }

    Ok(())
}



================================================
FILE: packages/desktop/src-tauri/src/lib.rs
================================================
mod commands;
mod config;
mod engine;
mod fs;
mod opkg;
mod openwork_server;
mod owpenbot;
mod paths;
mod platform;
mod types;
mod updater;
mod utils;
mod workspace;

pub use types::*;

use commands::command_files::{
    opencode_command_delete, opencode_command_list, opencode_command_write,
};
use commands::config::{read_opencode_config, write_opencode_config};
use commands::engine::{engine_doctor, engine_info, engine_install, engine_start, engine_stop};
use commands::misc::{opencode_mcp_auth, reset_opencode_cache, reset_openwork_state};
use commands::openwork_server::openwork_server_info;
use commands::opkg::{import_skill, opkg_install};
use commands::owpenbot::{
    owpenbot_config_set, owpenbot_info, owpenbot_pairing_approve, owpenbot_pairing_deny,
    owpenbot_pairing_list, owpenbot_qr, owpenbot_start, owpenbot_status, owpenbot_stop,
};
use commands::skills::{install_skill_template, list_local_skills, uninstall_skill};
use commands::updater::updater_environment;
use commands::workspace::{
    workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote,
    workspace_export_config, workspace_forget, workspace_import_config, workspace_openwork_read,
    workspace_openwork_write, workspace_set_active, workspace_update_remote,
};
use engine::manager::EngineManager;
use openwork_server::manager::OpenworkServerManager;
use owpenbot::manager::OwpenbotManager;
use workspace::watch::WorkspaceWatchState;

pub fn run() {
    let builder = tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_opener::init());

    #[cfg(desktop)]
    let builder = builder
        .plugin(tauri_plugin_process::init())
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_updater::Builder::new().build());

    builder
        .manage(EngineManager::default())
        .manage(OpenworkServerManager::default())
        .manage(OwpenbotManager::default())
        .manage(WorkspaceWatchState::default())
        .invoke_handler(tauri::generate_handler![
            engine_start,
            engine_stop,
            engine_info,
            engine_doctor,
            engine_install,
            openwork_server_info,
            owpenbot_info,
            owpenbot_start,
            owpenbot_stop,
            owpenbot_qr,
            owpenbot_status,
            owpenbot_config_set,
            owpenbot_pairing_list,
            owpenbot_pairing_approve,
            owpenbot_pairing_deny,
            workspace_bootstrap,
            workspace_set_active,
            workspace_create,
            workspace_create_remote,
            workspace_update_remote,
            workspace_forget,
            workspace_add_authorized_root,
            workspace_export_config,
            workspace_import_config,
            opencode_command_list,
            opencode_command_write,
            opencode_command_delete,
            workspace_openwork_read,
            workspace_openwork_write,
            opkg_install,
            import_skill,
            install_skill_template,
            list_local_skills,
            uninstall_skill,
            read_opencode_config,
            write_opencode_config,
            updater_environment,
            reset_openwork_state,
            reset_opencode_cache,
            opencode_mcp_auth
        ])
        .run(tauri::generate_context!())
        .expect("error while running OpenWork");
}



================================================
FILE: packages/desktop/src-tauri/src/main.rs
================================================
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
  openwork::run();
}



================================================
FILE: packages/desktop/src-tauri/src/opkg.rs
================================================
use std::process::{Command, Stdio};

use crate::platform::configure_hidden;
use crate::types::ExecResult;

pub fn run_capture_optional(command: &mut Command) -> Result<Option<ExecResult>, String> {
    match command.output() {
        Ok(output) => {
            let status = output.status.code().unwrap_or(-1);
            Ok(Some(ExecResult {
                ok: output.status.success(),
                status,
                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
            }))
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(format!(
            "Failed to run {}: {e}",
            command.get_program().to_string_lossy()
        )),
    }
}

pub fn opkg_install(project_dir: &str, package: &str) -> Result<ExecResult, String> {
    let mut opkg = Command::new("opkg");
    configure_hidden(&mut opkg);
    opkg.arg("install")
        .arg(package)
        .current_dir(project_dir)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if let Some(result) = run_capture_optional(&mut opkg)? {
        return Ok(result);
    }

    let mut openpackage = Command::new("openpackage");
    configure_hidden(&mut openpackage);
    openpackage
        .arg("install")
        .arg(package)
        .current_dir(project_dir)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if let Some(result) = run_capture_optional(&mut openpackage)? {
        return Ok(result);
    }

    let mut pnpm = Command::new("pnpm");
    configure_hidden(&mut pnpm);
    pnpm.arg("dlx")
        .arg("opkg")
        .arg("install")
        .arg(package)
        .current_dir(project_dir)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if let Some(result) = run_capture_optional(&mut pnpm)? {
        return Ok(result);
    }

    let mut npx = Command::new("npx");
    configure_hidden(&mut npx);
    npx.arg("opkg")
        .arg("install")
        .arg(package)
        .current_dir(project_dir)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if let Some(result) = run_capture_optional(&mut npx)? {
        return Ok(result);
    }

    Ok(ExecResult {
    ok: false,
    status: -1,
    stdout: String::new(),
    stderr: "OpenPackage CLI not found. Install with `npm install -g opkg` (or `openpackage`), or ensure pnpm/npx is available.".to_string(),
  })
}



================================================
FILE: packages/desktop/src-tauri/src/paths.rs
================================================
use std::env;
use std::path::{Path, PathBuf};

#[cfg(target_os = "macos")]
const MACOS_APP_SUPPORT_DIR: &str = "Library/Application Support";

pub fn home_dir() -> Option<PathBuf> {
    if let Ok(home) = env::var("HOME") {
        if !home.trim().is_empty() {
            return Some(PathBuf::from(home));
        }
    }

    if let Ok(profile) = env::var("USERPROFILE") {
        if !profile.trim().is_empty() {
            return Some(PathBuf::from(profile));
        }
    }

    None
}

pub fn candidate_xdg_data_dirs() -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    let Some(home) = home_dir() else {
        return candidates;
    };

    candidates.push(home.join(".local").join("share"));
    candidates.push(home.join(".config"));

    #[cfg(target_os = "macos")]
    {
        candidates.push(home.join(MACOS_APP_SUPPORT_DIR));
    }

    candidates
}

pub fn candidate_xdg_config_dirs() -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    let Some(home) = home_dir() else {
        return candidates;
    };

    candidates.push(home.join(".config"));

    #[cfg(target_os = "macos")]
    {
        candidates.push(home.join(MACOS_APP_SUPPORT_DIR));
    }

    candidates
}

pub fn maybe_infer_xdg_home(
    var_name: &str,
    candidates: Vec<PathBuf>,
    relative_marker: &Path,
) -> Option<String> {
    if env::var_os(var_name).is_some() {
        return None;
    }

    for base in candidates {
        if base.join(relative_marker).is_file() {
            return Some(base.to_string_lossy().to_string());
        }
    }

    None
}

pub fn path_entries() -> Vec<PathBuf> {
    let mut entries = Vec::new();
    let Some(path) = env::var_os("PATH") else {
        return entries;
    };

    entries.extend(env::split_paths(&path));
    entries
}

pub fn resolve_in_path(name: &str) -> Option<PathBuf> {
    for dir in path_entries() {
        let candidate = dir.join(name);
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}



================================================
FILE: packages/desktop/src-tauri/src/types.rs
================================================
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceOpenworkConfig {
    pub version: u32,
    pub workspace: Option<WorkspaceOpenworkWorkspace>,
    #[serde(default, alias = "authorizedRoots")]
    pub authorized_roots: Vec<String>,
}

impl Default for WorkspaceOpenworkConfig {
    fn default() -> Self {
        Self {
            version: 1,
            workspace: None,
            authorized_roots: Vec::new(),
        }
    }
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceOpenworkWorkspace {
    pub name: Option<String>,
    #[serde(default, alias = "createdAt")]
    pub created_at: Option<u64>,
    #[serde(default, alias = "preset")]
    pub preset: Option<String>,
}

impl WorkspaceOpenworkConfig {
    pub fn new(workspace_path: &str, preset: &str, now_ms: u64) -> Self {
        let root = std::path::PathBuf::from(workspace_path);
        let inferred_name = root
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("Workspace")
            .to_string();

        Self {
            version: 1,
            workspace: Some(WorkspaceOpenworkWorkspace {
                name: Some(inferred_name),
                created_at: Some(now_ms),
                preset: Some(preset.to_string()),
            }),
            authorized_roots: vec![workspace_path.to_string()],
        }
    }
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineInfo {
    pub running: bool,
    pub base_url: Option<String>,
    pub project_dir: Option<String>,
    pub hostname: Option<String>,
    pub port: Option<u16>,
    pub pid: Option<u32>,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpenworkServerInfo {
    pub running: bool,
    pub host: Option<String>,
    pub port: Option<u16>,
    pub base_url: Option<String>,
    pub connect_url: Option<String>,
    pub mdns_url: Option<String>,
    pub lan_url: Option<String>,
    pub client_token: Option<String>,
    pub host_token: Option<String>,
    pub pid: Option<u32>,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OwpenbotInfo {
    pub running: bool,
    pub workspace_path: Option<String>,
    pub opencode_url: Option<String>,
    pub qr_data: Option<String>,
    pub whatsapp_linked: bool,
    pub telegram_configured: bool,
    pub pid: Option<u32>,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineDoctorResult {
    pub found: bool,
    pub in_path: bool,
    pub resolved_path: Option<String>,
    pub version: Option<String>,
    pub supports_serve: bool,
    pub notes: Vec<String>,
    pub serve_help_status: Option<i32>,
    pub serve_help_stdout: Option<String>,
    pub serve_help_stderr: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ExecResult {
    pub ok: bool,
    pub status: i32,
    pub stdout: String,
    pub stderr: String,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpencodeConfigFile {
    pub path: String,
    pub exists: bool,
    pub content: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdaterEnvironment {
    pub supported: bool,
    pub reason: Option<String>,
    pub executable_path: Option<String>,
    pub app_bundle_path: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceType {
    Local,
    Remote,
}

impl Default for WorkspaceType {
    fn default() -> Self {
        WorkspaceType::Local
    }
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RemoteType {
    Opencode,
    Openwork,
}

impl Default for RemoteType {
    fn default() -> Self {
        RemoteType::Opencode
    }
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceInfo {
    pub id: String,
    pub name: String,
    pub path: String,
    pub preset: String,
    #[serde(default)]
    pub workspace_type: WorkspaceType,
    #[serde(default)]
    pub remote_type: Option<RemoteType>,
    #[serde(default)]
    pub base_url: Option<String>,
    #[serde(default)]
    pub directory: Option<String>,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default)]
    pub openwork_host_url: Option<String>,
    #[serde(default)]
    pub openwork_workspace_id: Option<String>,
    #[serde(default)]
    pub openwork_workspace_name: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceList {
    pub active_id: String,
    pub workspaces: Vec<WorkspaceInfo>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpencodeCommand {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    pub template: String,
    #[serde(default)]
    pub agent: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub subtask: Option<bool>,
}

fn default_workspace_state_version() -> u8 {
    1
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceState {
    #[serde(default = "default_workspace_state_version")]
    pub version: u8,
    pub active_id: String,
    pub workspaces: Vec<WorkspaceInfo>,
}

impl Default for WorkspaceState {
    fn default() -> Self {
        Self {
            version: WORKSPACE_STATE_VERSION,
            active_id: "starter".to_string(),
            workspaces: Vec::new(),
        }
    }
}

pub const WORKSPACE_STATE_VERSION: u8 = 3;



================================================
FILE: packages/desktop/src-tauri/src/updater.rs
================================================
use std::path::Path;

use crate::types::UpdaterEnvironment;

fn is_mac_dmg_or_translocated(path: &Path) -> bool {
    let path_str = path.to_string_lossy();
    path_str.contains("/Volumes/") || path_str.contains("AppTranslocation")
}

pub fn updater_environment() -> UpdaterEnvironment {
    let executable_path = std::env::current_exe().ok();

    let app_bundle_path = executable_path
        .as_ref()
        .and_then(|exe| exe.parent())
        .and_then(|p| p.parent())
        .and_then(|p| p.parent())
        .map(|p| p.to_path_buf());

    let mut supported = true;
    let mut reason: Option<String> = None;

    if let Some(exe) = executable_path.as_ref() {
        if is_mac_dmg_or_translocated(exe) {
            supported = false;
            reason = Some(
        "OpenWork is running from a mounted disk image. Install it to Applications to enable updates."
          .to_string(),
      );
        }
    }

    if supported {
        if let Some(bundle) = app_bundle_path.as_ref() {
            if is_mac_dmg_or_translocated(bundle) {
                supported = false;
                reason = Some(
          "OpenWork is running from a mounted disk image. Install it to Applications to enable updates."
            .to_string(),
        );
            }
        }
    }

    UpdaterEnvironment {
        supported,
        reason,
        executable_path: executable_path.map(|p| p.to_string_lossy().to_string()),
        app_bundle_path: app_bundle_path.map(|p| p.to_string_lossy().to_string()),
    }
}



================================================
FILE: packages/desktop/src-tauri/src/utils.rs
================================================
use std::time::{SystemTime, UNIX_EPOCH};

pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}

pub fn truncate_output(input: &str, max_chars: usize) -> String {
    if input.len() <= max_chars {
        return input.to_string();
    }

    input
        .chars()
        .skip(input.chars().count() - max_chars)
        .collect()
}



================================================
FILE: packages/desktop/src-tauri/src/commands/command_files.rs
================================================
use std::env;
use std::fs;
use std::path::PathBuf;

use crate::types::{ExecResult, OpencodeCommand};
use crate::workspace::commands::{sanitize_command_name, serialize_command_frontmatter};

fn resolve_commands_dir(scope: &str, project_dir: &str) -> Result<PathBuf, String> {
    match scope {
        "workspace" => {
            if project_dir.trim().is_empty() {
                return Err("projectDir is required".to_string());
            }
            Ok(PathBuf::from(project_dir)
                .join(".opencode")
                .join("commands"))
        }
        "global" => {
            let base = if let Ok(dir) = env::var("XDG_CONFIG_HOME") {
                PathBuf::from(dir)
            } else if let Ok(home) = env::var("HOME") {
                PathBuf::from(home).join(".config")
            } else {
                return Err("Unable to resolve config directory".to_string());
            };
            Ok(base.join("opencode").join("commands"))
        }
        _ => Err("scope must be 'workspace' or 'global'".to_string()),
    }
}

fn list_command_names(dir: &PathBuf) -> Result<Vec<String>, String> {
    if !dir.exists() {
        return Ok(Vec::new());
    }

    let mut names = Vec::new();
    for entry in fs::read_dir(dir).map_err(|e| format!("Failed to read {}: {e}", dir.display()))? {
        let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
            continue;
        }
        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            names.push(stem.to_string());
        }
    }
    names.sort();
    Ok(names)
}

#[tauri::command]
pub fn opencode_command_list(scope: String, project_dir: String) -> Result<Vec<String>, String> {
    let dir = resolve_commands_dir(scope.trim(), project_dir.trim())?;
    list_command_names(&dir)
}

#[tauri::command]
pub fn opencode_command_write(
    scope: String,
    project_dir: String,
    command: OpencodeCommand,
) -> Result<ExecResult, String> {
    let scope = scope.trim();
    let safe_name = sanitize_command_name(&command.name)
        .ok_or_else(|| "command.name is required".to_string())?;

    let dir = resolve_commands_dir(scope, project_dir.trim())?;
    if let Some(parent) = dir.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }
    fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;

    let payload = OpencodeCommand {
        name: safe_name.clone(),
        ..command
    };
    let file_path = dir.join(format!("{safe_name}.md"));
    let serialized = serialize_command_frontmatter(&payload)?;
    fs::write(&file_path, serialized)
        .map_err(|e| format!("Failed to write {}: {e}", file_path.display()))?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Wrote {}", file_path.display()),
        stderr: String::new(),
    })
}

#[tauri::command]
pub fn opencode_command_delete(
    scope: String,
    project_dir: String,
    name: String,
) -> Result<ExecResult, String> {
    let scope = scope.trim();
    let safe_name = sanitize_command_name(&name).ok_or_else(|| "name is required".to_string())?;
    let dir = resolve_commands_dir(scope, project_dir.trim())?;
    let file_path = dir.join(format!("{safe_name}.md"));

    if file_path.exists() {
        fs::remove_file(&file_path)
            .map_err(|e| format!("Failed to delete {}: {e}", file_path.display()))?;
    }

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Deleted {}", file_path.display()),
        stderr: String::new(),
    })
}



================================================
FILE: packages/desktop/src-tauri/src/commands/config.rs
================================================
use crate::config::{read_opencode_config as read_inner, write_opencode_config as write_inner};
use crate::types::{ExecResult, OpencodeConfigFile};

#[tauri::command]
pub fn read_opencode_config(
    scope: String,
    project_dir: String,
) -> Result<OpencodeConfigFile, String> {
    read_inner(scope.trim(), &project_dir)
}

#[tauri::command]
pub fn write_opencode_config(
    scope: String,
    project_dir: String,
    content: String,
) -> Result<ExecResult, String> {
    write_inner(scope.trim(), &project_dir, &content)
}



================================================
FILE: packages/desktop/src-tauri/src/commands/engine.rs
================================================
use tauri::{AppHandle, Manager, State};

use crate::config::{read_opencode_config, write_opencode_config};
use crate::engine::doctor::{
    opencode_serve_help, opencode_version, resolve_engine_path, resolve_sidecar_candidate,
};
use crate::engine::manager::EngineManager;
use crate::engine::spawn::{find_free_port, spawn_engine};
use crate::commands::owpenbot::owpenbot_start;
use crate::openwork_server::{manager::OpenworkServerManager, resolve_connect_url, start_openwork_server};
use crate::owpenbot::manager::OwpenbotManager;
use crate::types::{EngineDoctorResult, EngineInfo, ExecResult};
use crate::utils::truncate_output;
use serde_json::json;
use tauri_plugin_shell::process::CommandEvent;

#[derive(Default)]
struct OutputState {
    stdout: String,
    stderr: String,
    exited: bool,
    exit_code: Option<i32>,
}

#[tauri::command]
pub fn engine_info(manager: State<EngineManager>) -> EngineInfo {
    let mut state = manager.inner.lock().expect("engine mutex poisoned");
    EngineManager::snapshot_locked(&mut state)
}

#[tauri::command]
pub fn engine_stop(
    manager: State<EngineManager>,
    openwork_manager: State<OpenworkServerManager>,
    owpenbot_manager: State<OwpenbotManager>,
) -> EngineInfo {
    let mut state = manager.inner.lock().expect("engine mutex poisoned");
    EngineManager::stop_locked(&mut state);
    if let Ok(mut openwork_state) = openwork_manager.inner.lock() {
        OpenworkServerManager::stop_locked(&mut openwork_state);
    }
    if let Ok(mut owpenbot_state) = owpenbot_manager.inner.lock() {
        OwpenbotManager::stop_locked(&mut owpenbot_state);
    }
    EngineManager::snapshot_locked(&mut state)
}

#[tauri::command]
pub fn engine_doctor(app: AppHandle, prefer_sidecar: Option<bool>) -> EngineDoctorResult {
    let prefer_sidecar = prefer_sidecar.unwrap_or(false);
    let resource_dir = app.path().resource_dir().ok();

    let current_bin_dir = tauri::process::current_binary(&app.env())
        .ok()
        .and_then(|path| path.parent().map(|parent| parent.to_path_buf()));

    let (resolved, in_path, notes) = resolve_engine_path(
        prefer_sidecar,
        resource_dir.as_deref(),
        current_bin_dir.as_deref(),
    );

    let (version, supports_serve, serve_help_status, serve_help_stdout, serve_help_stderr) =
        match resolved.as_ref() {
            Some(path) => {
                let (ok, status, stdout, stderr) = opencode_serve_help(path.as_os_str());
                (
                    opencode_version(path.as_os_str()),
                    ok,
                    status,
                    stdout,
                    stderr,
                )
            }
            None => (None, false, None, None, None),
        };

    EngineDoctorResult {
        found: resolved.is_some(),
        in_path,
        resolved_path: resolved.map(|path| path.to_string_lossy().to_string()),
        version,
        supports_serve,
        notes,
        serve_help_status,
        serve_help_stdout,
        serve_help_stderr,
    }
}

#[tauri::command]
pub fn engine_install() -> Result<ExecResult, String> {
    #[cfg(windows)]
    {
        return Ok(ExecResult {
      ok: false,
      status: -1,
      stdout: String::new(),
      stderr: "Guided install is not supported on Windows yet. Install OpenCode via Scoop/Chocolatey or https://opencode.ai/install, then restart OpenWork.".to_string(),
    });
    }

    #[cfg(not(windows))]
    {
        let install_dir = crate::paths::home_dir()
            .unwrap_or_else(|| std::path::PathBuf::from("."))
            .join(".opencode")
            .join("bin");

        let output = std::process::Command::new("bash")
            .arg("-lc")
            .arg("curl -fsSL https://opencode.ai/install | bash")
            .env("OPENCODE_INSTALL_DIR", install_dir)
            .output()
            .map_err(|e| format!("Failed to run installer: {e}"))?;

        let status = output.status.code().unwrap_or(-1);
        Ok(ExecResult {
            ok: output.status.success(),
            status,
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
        })
    }
}

#[tauri::command]
pub fn engine_start(
    app: AppHandle,
    manager: State<EngineManager>,
    openwork_manager: State<OpenworkServerManager>,
    owpenbot_manager: State<OwpenbotManager>,
    project_dir: String,
    prefer_sidecar: Option<bool>,
) -> Result<EngineInfo, String> {
    let project_dir = project_dir.trim().to_string();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    // OpenCode is spawned with `current_dir(project_dir)`. If the user selected a
    // workspace path that doesn't exist yet (common during onboarding), spawning
    // fails with `os error 2`.
    std::fs::create_dir_all(&project_dir)
        .map_err(|e| format!("Failed to create projectDir directory: {e}"))?;

    let config = read_opencode_config("project", &project_dir)?;
    if !config.exists {
        let content = serde_json::to_string_pretty(&json!({
            "$schema": "https://opencode.ai/config.json",
        }))
        .map_err(|e| format!("Failed to serialize opencode config: {e}"))?;
        let write_result = write_opencode_config("project", &project_dir, &format!("{content}\n"))?;
        if !write_result.ok {
            return Err(write_result.stderr);
        }
    }

    let bind_host = "0.0.0.0".to_string();
    let client_host = "127.0.0.1".to_string();
    let port = find_free_port()?;

    let mut state = manager.inner.lock().expect("engine mutex poisoned");
    EngineManager::stop_locked(&mut state);

    let resource_dir = app.path().resource_dir().ok();
    let current_bin_dir = tauri::process::current_binary(&app.env())
        .ok()
        .and_then(|path| path.parent().map(|parent| parent.to_path_buf()));
    let prefer_sidecar = prefer_sidecar.unwrap_or(false);
    let (program, _in_path, notes) =
        resolve_engine_path(prefer_sidecar, resource_dir.as_deref(), current_bin_dir.as_deref());
    let Some(program) = program else {
        let notes_text = notes.join("\n");
        return Err(format!(
      "OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}"
    ));
    };

    let (sidecar_candidate, _sidecar_notes) =
        resolve_sidecar_candidate(prefer_sidecar, resource_dir.as_deref(), current_bin_dir.as_deref());
    let use_sidecar = prefer_sidecar
        && sidecar_candidate
            .as_ref()
            .is_some_and(|candidate| candidate == &program);

    let (mut rx, child) =
        spawn_engine(&app, &program, &bind_host, port, &project_dir, use_sidecar)?;

    state.last_stdout = None;
    state.last_stderr = None;
    state.child_exited = false;

    let output_state = std::sync::Arc::new(std::sync::Mutex::new(OutputState::default()));
    let output_state_handle = output_state.clone();
    let state_handle = manager.inner.clone();

    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut output) = output_state_handle.lock() {
                        output.stdout.push_str(&line);
                    }
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state
                            .last_stdout
                            .as_deref()
                            .unwrap_or_default()
                            .to_string()
                            + &line;
                        state.last_stdout = Some(truncate_output(&next, 8000));
                    }
                }
                CommandEvent::Stderr(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut output) = output_state_handle.lock() {
                        output.stderr.push_str(&line);
                    }
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state
                            .last_stderr
                            .as_deref()
                            .unwrap_or_default()
                            .to_string()
                            + &line;
                        state.last_stderr = Some(truncate_output(&next, 8000));
                    }
                }
                CommandEvent::Terminated(payload) => {
                    if let Ok(mut output) = output_state_handle.lock() {
                        output.exited = true;
                        output.exit_code = payload.code;
                    }
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                    }
                }
                CommandEvent::Error(message) => {
                    if let Ok(mut output) = output_state_handle.lock() {
                        output.exited = true;
                        output.exit_code = Some(-1);
                        output.stderr.push_str(&message);
                    }
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                    }
                }
                _ => {}
            }
        }
    });

    let warmup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    loop {
        if let Ok(output) = output_state.lock() {
            if output.exited {
                let stdout = output.stdout.trim().to_string();
                let stderr = output.stderr.trim().to_string();

                let stdout = if stdout.is_empty() {
                    None
                } else {
                    Some(truncate_output(&stdout, 8000))
                };
                let stderr = if stderr.is_empty() {
                    None
                } else {
                    Some(truncate_output(&stderr, 8000))
                };

                let mut parts = Vec::new();
                if let Some(stdout) = stdout {
                    parts.push(format!("stdout:\n{stdout}"));
                }
                if let Some(stderr) = stderr {
                    parts.push(format!("stderr:\n{stderr}"));
                }

                let suffix = if parts.is_empty() {
                    String::new()
                } else {
                    format!("\n\n{}", parts.join("\n\n"))
                };

                return Err(format!(
                    "OpenCode exited immediately with status {}.{}",
                    output.exit_code.unwrap_or(-1),
                    suffix
                ));
            }
        }

        if std::time::Instant::now() >= warmup_deadline {
            break;
        }

        std::thread::sleep(std::time::Duration::from_millis(150));
    }

    state.child = Some(child);
    state.project_dir = Some(project_dir.clone());
    state.hostname = Some(client_host.clone());
    state.port = Some(port);
    state.base_url = Some(format!("http://{client_host}:{port}"));

    let opencode_connect_url = resolve_connect_url(port).unwrap_or_else(|| format!("http://{client_host}:{port}"));
    if let Err(error) = start_openwork_server(
        &app,
        &openwork_manager,
        &state.project_dir.clone().unwrap_or_default(),
        Some(&opencode_connect_url),
    ) {
        state.last_stderr = Some(truncate_output(&format!("OpenWork server: {error}"), 8000));
    }

    if let Err(error) = owpenbot_start(
        app.clone(),
        owpenbot_manager,
        project_dir.clone(),
        Some(opencode_connect_url),
    ) {
        state.last_stderr = Some(truncate_output(&format!("Owpenbot: {error}"), 8000));
    }

    Ok(EngineManager::snapshot_locked(&mut state))
}



================================================
FILE: packages/desktop/src-tauri/src/commands/misc.rs
================================================
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use crate::engine::doctor::resolve_engine_path;
use crate::paths::home_dir;
use crate::platform::command_for_program;
use crate::types::{ExecResult, WorkspaceOpenworkConfig};
use crate::workspace::state::load_workspace_state;
use tauri::{AppHandle, Manager};

#[derive(serde::Serialize)]
pub struct CacheResetResult {
    pub removed: Vec<String>,
    pub missing: Vec<String>,
    pub errors: Vec<String>,
}

fn opencode_cache_candidates() -> Vec<PathBuf> {
    let mut candidates: Vec<PathBuf> = Vec::new();

    if let Ok(value) = std::env::var("XDG_CACHE_HOME") {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            candidates.push(PathBuf::from(trimmed).join("opencode"));
        }
    }

    if let Some(home) = home_dir() {
        candidates.push(home.join(".cache").join("opencode"));

        #[cfg(target_os = "macos")]
        {
            candidates.push(home.join("Library").join("Caches").join("opencode"));
        }
    }

    #[cfg(windows)]
    {
        if let Ok(value) = std::env::var("LOCALAPPDATA") {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                candidates.push(PathBuf::from(trimmed).join("opencode"));
            }
        }
        if let Ok(value) = std::env::var("APPDATA") {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                candidates.push(PathBuf::from(trimmed).join("opencode"));
            }
        }
    }

    let mut seen = HashSet::new();
    candidates
        .into_iter()
        .filter(|path| seen.insert(path.to_string_lossy().to_string()))
        .collect()
}

fn validate_server_name(name: &str) -> Result<String, String> {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        return Err("server_name is required".to_string());
    }

    if trimmed.starts_with('-') {
        return Err("server_name must not start with '-'".to_string());
    }

    if !trimmed
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err("server_name must be alphanumeric with '-' or '_'".to_string());
    }

    Ok(trimmed.to_string())
}

fn read_workspace_openwork_config(
    workspace_path: &Path,
) -> Result<WorkspaceOpenworkConfig, String> {
    let openwork_path = workspace_path.join(".opencode").join("openwork.json");
    if !openwork_path.exists() {
        let mut cfg = WorkspaceOpenworkConfig::default();
        let workspace_value = workspace_path.to_string_lossy().to_string();
        if !workspace_value.trim().is_empty() {
            cfg.authorized_roots.push(workspace_value);
        }
        return Ok(cfg);
    }

    let raw = fs::read_to_string(&openwork_path)
        .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?;

    serde_json::from_str::<WorkspaceOpenworkConfig>(&raw)
        .map_err(|e| format!("Failed to parse {}: {e}", openwork_path.display()))
}

fn load_authorized_roots(app: &AppHandle) -> Result<Vec<PathBuf>, String> {
    let state = load_workspace_state(app)?;
    let mut roots = Vec::new();

    for workspace in state.workspaces {
        let workspace_path = PathBuf::from(&workspace.path);
        let mut config = read_workspace_openwork_config(&workspace_path)?;

        if config.authorized_roots.is_empty() {
            config.authorized_roots.push(workspace.path.clone());
        }

        for root in config.authorized_roots {
            let trimmed = root.trim();
            if !trimmed.is_empty() {
                roots.push(PathBuf::from(trimmed));
            }
        }
    }

    if roots.is_empty() {
        return Err("No authorized roots configured".to_string());
    }

    Ok(roots)
}

fn validate_project_dir(app: &AppHandle, project_dir: &str) -> Result<PathBuf, String> {
    let trimmed = project_dir.trim();
    if trimmed.is_empty() {
        return Err("project_dir is required".to_string());
    }

    let project_path = PathBuf::from(trimmed);
    if !project_path.is_absolute() {
        return Err("project_dir must be an absolute path".to_string());
    }

    let canonical = fs::canonicalize(&project_path)
        .map_err(|e| format!("Failed to resolve project_dir: {e}"))?;

    if !canonical.is_dir() {
        return Err("project_dir must be a directory".to_string());
    }

    let roots = load_authorized_roots(app)?;
    let mut allowed = false;
    for root in roots {
        let Ok(root) = fs::canonicalize(&root) else {
            continue;
        };
        if canonical.starts_with(&root) {
            allowed = true;
            break;
        }
    }

    if !allowed {
        return Err("project_dir is not within an authorized root".to_string());
    }

    Ok(canonical)
}

#[tauri::command]
pub fn reset_opencode_cache() -> Result<CacheResetResult, String> {
    let candidates = opencode_cache_candidates();
    let mut removed = Vec::new();
    let mut missing = Vec::new();
    let mut errors = Vec::new();

    for path in candidates {
        if path.exists() {
            if let Err(err) = std::fs::remove_dir_all(&path) {
                errors.push(format!("Failed to remove {}: {err}", path.display()));
            } else {
                removed.push(path.to_string_lossy().to_string());
            }
        } else {
            missing.push(path.to_string_lossy().to_string());
        }
    }

    Ok(CacheResetResult {
        removed,
        missing,
        errors,
    })
}

#[tauri::command]
pub fn reset_openwork_state(app: tauri::AppHandle, mode: String) -> Result<(), String> {
    let mode = mode.trim();
    if mode != "onboarding" && mode != "all" {
        return Err("mode must be 'onboarding' or 'all'".to_string());
    }

    let cache_dir = app
        .path()
        .app_cache_dir()
        .map_err(|e| format!("Failed to resolve app cache dir: {e}"))?;

    if cache_dir.exists() {
        std::fs::remove_dir_all(&cache_dir)
            .map_err(|e| format!("Failed to remove cache dir {}: {e}", cache_dir.display()))?;
    }

    if mode == "all" {
        let data_dir = app
            .path()
            .app_data_dir()
            .map_err(|e| format!("Failed to resolve app data dir: {e}"))?;

        if data_dir.exists() {
            std::fs::remove_dir_all(&data_dir)
                .map_err(|e| format!("Failed to remove data dir {}: {e}", data_dir.display()))?;
        }
    }

    Ok(())
}

/// Run `opencode mcp auth <server_name>` in the given project directory.
/// This spawns the process detached so the OAuth flow can open a browser.
#[tauri::command]
pub fn opencode_mcp_auth(
    app: AppHandle,
    project_dir: String,
    server_name: String,
) -> Result<ExecResult, String> {
    let project_dir = validate_project_dir(&app, &project_dir)?;
    let server_name = validate_server_name(&server_name)?;

    let resource_dir = app.path().resource_dir().ok();
    let current_bin_dir = tauri::process::current_binary(&app.env())
        .ok()
        .and_then(|path| path.parent().map(|parent| parent.to_path_buf()));
    let (program, _in_path, notes) =
        resolve_engine_path(true, resource_dir.as_deref(), current_bin_dir.as_deref());
    let Some(program) = program else {
        let notes_text = notes.join("\n");
        return Err(format!(
      "OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}"
    ));
    };

    let output = command_for_program(&program)
        .arg("mcp")
        .arg("auth")
        .arg(server_name)
        .current_dir(&project_dir)
        .output()
        .map_err(|e| format!("Failed to run opencode mcp auth: {e}"))?;

    let status = output.status.code().unwrap_or(-1);
    Ok(ExecResult {
        ok: output.status.success(),
        status,
        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
    })
}



================================================
FILE: packages/desktop/src-tauri/src/commands/mod.rs
================================================
pub mod command_files;
pub mod config;
pub mod engine;
pub mod misc;
pub mod openwork_server;
pub mod opkg;
pub mod owpenbot;
pub mod skills;
pub mod updater;
pub mod workspace;



================================================
FILE: packages/desktop/src-tauri/src/commands/openwork_server.rs
================================================
use tauri::State;

use crate::openwork_server::manager::OpenworkServerManager;
use crate::types::OpenworkServerInfo;

#[tauri::command]
pub fn openwork_server_info(manager: State<OpenworkServerManager>) -> OpenworkServerInfo {
    let mut state = manager.inner.lock().expect("openwork server mutex poisoned");
    OpenworkServerManager::snapshot_locked(&mut state)
}

// start/stop are handled by engine lifecycle



================================================
FILE: packages/desktop/src-tauri/src/commands/opkg.rs
================================================
use crate::fs::copy_dir_recursive;
use crate::opkg::opkg_install as opkg_install_inner;
use crate::types::ExecResult;

#[tauri::command]
pub fn opkg_install(project_dir: String, package: String) -> Result<ExecResult, String> {
    let project_dir = project_dir.trim().to_string();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let package = package.trim().to_string();
    if package.is_empty() {
        return Err("package is required".to_string());
    }

    opkg_install_inner(&project_dir, &package)
}

#[tauri::command]
pub fn import_skill(
    project_dir: String,
    source_dir: String,
    overwrite: bool,
) -> Result<ExecResult, String> {
    let project_dir = project_dir.trim().to_string();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let source_dir = source_dir.trim().to_string();
    if source_dir.is_empty() {
        return Err("sourceDir is required".to_string());
    }

    let src = std::path::PathBuf::from(&source_dir);
    let name = src
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| "Failed to infer skill name from directory".to_string())?;

    let dest = std::path::PathBuf::from(&project_dir)
        .join(".opencode")
        .join("skills")
        .join(name);

    if dest.exists() {
        if overwrite {
            std::fs::remove_dir_all(&dest).map_err(|e| {
                format!(
                    "Failed to remove existing skill dir {}: {e}",
                    dest.display()
                )
            })?;
        } else {
            return Err(format!("Skill already exists at {}", dest.display()));
        }
    }

    copy_dir_recursive(&src, &dest)?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Imported skill to {}", dest.display()),
        stderr: String::new(),
    })
}



================================================
FILE: packages/desktop/src-tauri/src/commands/owpenbot.rs
================================================
use tauri::{AppHandle, State};
use tauri_plugin_shell::process::CommandEvent;

use crate::owpenbot::manager::OwpenbotManager;
use crate::owpenbot::spawn::spawn_owpenbot;
use crate::types::OwpenbotInfo;
use crate::utils::truncate_output;

#[tauri::command]
pub async fn owpenbot_info(
    app: AppHandle,
    manager: State<'_, OwpenbotManager>,
) -> Result<OwpenbotInfo, String> {
    let mut info = {
        let mut state = manager
            .inner
            .lock()
            .map_err(|_| "owpenbot mutex poisoned".to_string())?;
        OwpenbotManager::snapshot_locked(&mut state)
    };

    // Only fetch from CLI status if manager doesn't have values (fallback for when sidecar isn't started)
    if info.opencode_url.is_none() || info.workspace_path.is_none() {
        if let Ok(status) = owpenbot_json(&app, &["status", "--json"], "get status").await {
            if let Some(opencode) = status.get("opencode") {
                if info.opencode_url.is_none() {
                    if let Some(url) = opencode.get("url").and_then(|value| value.as_str()) {
                        let trimmed = url.trim();
                        if !trimmed.is_empty() {
                            info.opencode_url = Some(trimmed.to_string());
                        }
                    }
                }
                if info.workspace_path.is_none() {
                    if let Some(directory) = opencode.get("directory").and_then(|value| value.as_str()) {
                        let trimmed = directory.trim();
                        if !trimmed.is_empty() {
                            info.workspace_path = Some(trimmed.to_string());
                        }
                    }
                }
            }

            if let Some(whatsapp) = status.get("whatsapp") {
                if let Some(linked) = whatsapp.get("linked").and_then(|value| value.as_bool()) {
                    info.whatsapp_linked = linked;
                }
            }

            if let Some(telegram) = status.get("telegram") {
                if let Some(configured) = telegram.get("configured").and_then(|value| value.as_bool()) {
                    info.telegram_configured = configured;
                }
            }
        }
    }

    Ok(info)
}

#[tauri::command]
pub fn owpenbot_start(
    app: AppHandle,
    manager: State<OwpenbotManager>,
    workspace_path: String,
    opencode_url: Option<String>,
) -> Result<OwpenbotInfo, String> {
    let mut state = manager
        .inner
        .lock()
        .map_err(|_| "owpenbot mutex poisoned".to_string())?;
    OwpenbotManager::stop_locked(&mut state);

    let (mut rx, child) = spawn_owpenbot(&app, &workspace_path, opencode_url.as_deref())?;

    state.child = Some(child);
    state.child_exited = false;
    state.workspace_path = Some(workspace_path);
    state.opencode_url = opencode_url;
    state.last_stdout = None;
    state.last_stderr = None;

    let state_handle = manager.inner.clone();

    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state
                            .last_stdout
                            .as_deref()
                            .unwrap_or_default()
                            .to_string()
                            + &line;
                        state.last_stdout = Some(truncate_output(&next, 8000));

                        // Check for WhatsApp linked status in output
                        if line.contains("WhatsApp linked") {
                            state.whatsapp_linked = true;
                        }
                    }
                }
                CommandEvent::Stderr(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state
                            .last_stderr
                            .as_deref()
                            .unwrap_or_default()
                            .to_string()
                            + &line;
                        state.last_stderr = Some(truncate_output(&next, 8000));
                    }
                }
                CommandEvent::Terminated(payload) => {
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                        if let Some(code) = payload.code {
                            let next = format!("Owpenbot exited (code {code}).");
                            state.last_stderr = Some(truncate_output(&next, 8000));
                        }
                    }
                }
                CommandEvent::Error(message) => {
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                        let next = state
                            .last_stderr
                            .as_deref()
                            .unwrap_or_default()
                            .to_string()
                            + &message;
                        state.last_stderr = Some(truncate_output(&next, 8000));
                    }
                }
                _ => {}
            }
        }
    });

    Ok(OwpenbotManager::snapshot_locked(&mut state))
}

#[tauri::command]
pub fn owpenbot_stop(manager: State<OwpenbotManager>) -> Result<OwpenbotInfo, String> {
    let mut state = manager
        .inner
        .lock()
        .map_err(|_| "owpenbot mutex poisoned".to_string())?;
    OwpenbotManager::stop_locked(&mut state);
    Ok(OwpenbotManager::snapshot_locked(&mut state))
}

#[tauri::command]
pub async fn owpenbot_qr(app: AppHandle) -> Result<String, String> {
    use tauri_plugin_shell::ShellExt;
    use base64::engine::general_purpose;
    use base64::Engine as _;
    use image::{DynamicImage, ImageFormat, Luma};
    use qrcode::QrCode;
    use std::io::Cursor;

    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let output = command
        .args(["whatsapp", "qr", "--format", "ascii", "--json"])
        .output()
        .await
        .map_err(|e| format!("Failed to get QR code: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Failed to get QR code: {stderr}"));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);

    // Parse JSON response
    #[derive(serde::Deserialize)]
    struct QrResponse {
        qr: Option<String>,
        error: Option<String>,
    }

    let response: QrResponse =
        serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse QR response: {e}"))?;

    if let Some(error) = response.error {
        return Err(error);
    }

    let qr_data = response.qr.ok_or_else(|| "No QR code returned".to_string())?;
    let code = QrCode::new(qr_data.as_bytes()).map_err(|e| format!("Failed to encode QR: {e}"))?;
    let image = code
        .render::<Luma<u8>>()
        .min_dimensions(256, 256)
        .build();
    let mut buffer = Vec::new();
    DynamicImage::ImageLuma8(image)
        .write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png)
        .map_err(|e| format!("Failed to encode QR image: {e}"))?;
    Ok(general_purpose::STANDARD.encode(buffer))
}

#[tauri::command]
pub async fn owpenbot_status(
    app: AppHandle,
    manager: State<'_, OwpenbotManager>,
) -> Result<serde_json::Value, String> {
    let status = owpenbot_json(&app, &["status", "--json"], "get status").await?;
    let whatsapp = owpenbot_json(&app, &["whatsapp", "status", "--json"], "get WhatsApp status").await?;
    let telegram = owpenbot_json(&app, &["telegram", "status", "--json"], "get Telegram status").await?;

    let running = {
        let mut state = manager
            .inner
            .lock()
            .map_err(|_| "owpenbot mutex poisoned".to_string())?;
        OwpenbotManager::snapshot_locked(&mut state).running
    };

    let config_path = status
        .get("config")
        .and_then(|value| value.as_str())
        .unwrap_or_default();
    let opencode_url = status
        .get("opencode")
        .and_then(|value| value.get("url"))
        .and_then(|value| value.as_str())
        .unwrap_or_default();

    let whatsapp_linked = whatsapp
        .get("linked")
        .and_then(|value| value.as_bool())
        .unwrap_or(false);
    let whatsapp_dm_policy = whatsapp
        .get("dmPolicy")
        .and_then(|value| value.as_str())
        .unwrap_or("pairing");
    let whatsapp_allow_from = whatsapp
        .get("allowFrom")
        .and_then(|value| value.as_array())
        .cloned()
        .unwrap_or_default();

    let telegram_configured = telegram
        .get("configured")
        .and_then(|value| value.as_bool())
        .unwrap_or(false);
    let telegram_enabled = telegram
        .get("enabled")
        .and_then(|value| value.as_bool())
        .unwrap_or(false);

    Ok(serde_json::json!({
        "running": running,
        "config": config_path,
        "whatsapp": {
            "linked": whatsapp_linked,
            "dmPolicy": whatsapp_dm_policy,
            "allowFrom": whatsapp_allow_from,
        },
        "telegram": {
            "configured": telegram_configured,
            "enabled": telegram_enabled,
        },
        "opencode": {
            "url": opencode_url,
        },
    }))
}

#[tauri::command]
pub async fn owpenbot_config_set(
    app: AppHandle,
    key: String,
    value: String,
) -> Result<(), String> {
    use tauri_plugin_shell::ShellExt;

    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let output = command
        .args(["config", "set", &key, &value])
        .output()
        .await
        .map_err(|e| format!("Failed to set config: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Failed to set config: {stderr}"));
    }

    Ok(())
}

#[tauri::command]
pub async fn owpenbot_pairing_list(app: AppHandle) -> Result<serde_json::Value, String> {
    owpenbot_json(&app, &["pairing", "list", "--json"], "list pairing requests").await
}

async fn owpenbot_json(
    app: &AppHandle,
    args: &[&str],
    context: &str,
) -> Result<serde_json::Value, String> {
    use tauri_plugin_shell::ShellExt;

    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let output = command
        .args(args)
        .output()
        .await
        .map_err(|e| format!("Failed to {context}: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Failed to {context}: {stderr}"));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse {context}: {e}"))
}

#[tauri::command]
pub async fn owpenbot_pairing_approve(app: AppHandle, code: String) -> Result<(), String> {
    use tauri_plugin_shell::ShellExt;

    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let output = command
        .args(["pairing", "approve", &code])
        .output()
        .await
        .map_err(|e| format!("Failed to approve pairing: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Failed to approve pairing: {stderr}"));
    }

    Ok(())
}

#[tauri::command]
pub async fn owpenbot_pairing_deny(app: AppHandle, code: String) -> Result<(), String> {
    use tauri_plugin_shell::ShellExt;

    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let output = command
        .args(["pairing", "deny", &code])
        .output()
        .await
        .map_err(|e| format!("Failed to deny pairing: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Failed to deny pairing: {stderr}"));
    }

    Ok(())
}



================================================
FILE: packages/desktop/src-tauri/src/commands/skills.rs
================================================
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use crate::paths::{candidate_xdg_config_dirs, home_dir};
use crate::types::ExecResult;

fn ensure_project_skill_root(project_dir: &str) -> Result<PathBuf, String> {
    let project_dir = project_dir.trim();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let base = PathBuf::from(project_dir).join(".opencode");
    let legacy = base.join("skill");
    let modern = base.join("skills");

    if legacy.is_dir() && !modern.exists() {
        fs::rename(&legacy, &modern).map_err(|e| {
            format!(
                "Failed to move {} -> {}: {e}",
                legacy.display(),
                modern.display()
            )
        })?;
    }

    fs::create_dir_all(&modern)
        .map_err(|e| format!("Failed to create {}: {e}", modern.display()))?;
    Ok(modern)
}

fn collect_project_skill_roots(project_dir: &Path) -> Vec<PathBuf> {
    let mut roots = Vec::new();
    let mut current = Some(project_dir);

    while let Some(dir) = current {
        let opencode_root = dir.join(".opencode").join("skills");
        if opencode_root.is_dir() {
            roots.push(opencode_root);
        } else {
            let legacy_root = dir.join(".opencode").join("skill");
            if legacy_root.is_dir() {
                roots.push(legacy_root);
            }
        }

        let claude_root = dir.join(".claude").join("skills");
        if claude_root.is_dir() {
            roots.push(claude_root);
        }

        if dir.join(".git").exists() {
            break;
        }

        current = dir.parent();
    }

    roots
}

fn collect_global_skill_roots() -> Vec<PathBuf> {
    let mut roots = Vec::new();
    for dir in candidate_xdg_config_dirs() {
        let opencode_root = dir.join("opencode").join("skills");
        if opencode_root.is_dir() {
            roots.push(opencode_root);
        }
    }

    if let Some(home) = home_dir() {
        let claude_root = home.join(".claude").join("skills");
        if claude_root.is_dir() {
            roots.push(claude_root);
        }
    }

    roots
}

fn collect_skill_roots(project_dir: &str) -> Result<Vec<PathBuf>, String> {
    let project_dir = project_dir.trim();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let mut roots = Vec::new();
    let project_path = PathBuf::from(project_dir);
    roots.extend(collect_project_skill_roots(&project_path));
    roots.extend(collect_global_skill_roots());

    let mut seen = HashSet::new();
    let mut unique = Vec::new();
    for root in roots {
        let key = root.to_string_lossy().to_string();
        if seen.insert(key) {
            unique.push(root);
        }
    }

    Ok(unique)
}

fn validate_skill_name(name: &str) -> Result<String, String> {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        return Err("skill name is required".to_string());
    }

    if !trimmed
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err("skill name must be kebab-case".to_string());
    }

    if trimmed.starts_with('-') || trimmed.ends_with('-') || trimmed.contains("--") {
        return Err("skill name must be kebab-case".to_string());
    }

    Ok(trimmed.to_string())
}

fn gather_skills(
    root: &Path,
    seen: &mut HashSet<String>,
    out: &mut Vec<PathBuf>,
) -> Result<(), String> {
    if !root.is_dir() {
        return Ok(());
    }

    for entry in
        fs::read_dir(root).map_err(|e| format!("Failed to read {}: {e}", root.display()))?
    {
        let entry = entry.map_err(|e| e.to_string())?;
        let file_type = entry.file_type().map_err(|e| e.to_string())?;
        if !file_type.is_dir() {
            continue;
        }

        let path = entry.path();
        if !path.join("SKILL.md").is_file() {
            continue;
        }

        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };

        if seen.insert(name.to_string()) {
            out.push(path);
        }
    }

    Ok(())
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LocalSkillCard {
    pub name: String,
    pub path: String,
    pub description: Option<String>,
}

fn extract_description(raw: &str) -> Option<String> {
    // Keep this lightweight: take the first non-empty line that isn't a header or frontmatter marker.
    let mut in_frontmatter = false;

    for line in raw.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if trimmed == "---" {
            in_frontmatter = !in_frontmatter;
            continue;
        }
        if in_frontmatter {
            continue;
        }
        if trimmed.starts_with('#') {
            continue;
        }

        let cleaned = trimmed.replace('`', "");
        if cleaned.is_empty() {
            continue;
        }

        let max = 180;
        if cleaned.len() > max {
            return Some(format!("{}...", &cleaned[..max]));
        }
        return Some(cleaned);
    }

    None
}

#[tauri::command]
pub fn list_local_skills(project_dir: String) -> Result<Vec<LocalSkillCard>, String> {
    let project_dir = project_dir.trim();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let skill_roots = collect_skill_roots(project_dir)?;
    let mut found: Vec<PathBuf> = Vec::new();
    let mut seen = HashSet::new();
    for root in skill_roots {
        gather_skills(&root, &mut seen, &mut found)?;
    }

    let mut out = Vec::new();
    for path in found {
        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };

        let description = match fs::read_to_string(path.join("SKILL.md")) {
            Ok(raw) => extract_description(&raw),
            Err(_) => None,
        };

        out.push(LocalSkillCard {
            name: name.to_string(),
            path: path.to_string_lossy().to_string(),
            description,
        });
    }

    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}

#[tauri::command]
pub fn install_skill_template(
    project_dir: String,
    name: String,
    content: String,
    overwrite: bool,
) -> Result<ExecResult, String> {
    let project_dir = project_dir.trim();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let name = validate_skill_name(&name)?;
    let skill_root = ensure_project_skill_root(project_dir)?;
    let dest = skill_root.join(&name);

    if dest.exists() {
        if overwrite {
            fs::remove_dir_all(&dest).map_err(|e| {
                format!(
                    "Failed to remove existing skill dir {}: {e}",
                    dest.display()
                )
            })?;
        } else {
            return Ok(ExecResult {
                ok: false,
                status: 1,
                stdout: String::new(),
                stderr: format!("Skill already exists at {}", dest.display()),
            });
        }
    }

    fs::create_dir_all(&dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
    fs::write(dest.join("SKILL.md"), content)
        .map_err(|e| format!("Failed to write SKILL.md: {e}"))?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Installed skill to {}", dest.display()),
        stderr: String::new(),
    })
}

#[tauri::command]
pub fn uninstall_skill(project_dir: String, name: String) -> Result<ExecResult, String> {
    let project_dir = project_dir.trim();
    if project_dir.is_empty() {
        return Err("projectDir is required".to_string());
    }

    let name = validate_skill_name(&name)?;
    let skill_roots = collect_skill_roots(project_dir)?;
    let mut removed = false;

    for root in skill_roots {
        let dest = root.join(&name);
        if !dest.exists() {
            continue;
        }

        fs::remove_dir_all(&dest)
            .map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
        removed = true;
    }

    if !removed {
        return Ok(ExecResult {
            ok: false,
            status: 1,
            stdout: String::new(),
            stderr: "Skill not found in .opencode/skills or .claude/skills".to_string(),
        });
    }

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Removed skill {}", name),
        stderr: String::new(),
    })
}



================================================
FILE: packages/desktop/src-tauri/src/commands/updater.rs
================================================
use crate::types::UpdaterEnvironment;
use crate::updater::updater_environment as updater_environment_inner;

#[tauri::command]
pub fn updater_environment(_app: tauri::AppHandle) -> UpdaterEnvironment {
    updater_environment_inner()
}



================================================
FILE: packages/desktop/src-tauri/src/commands/workspace.rs
================================================
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::types::{
    ExecResult, RemoteType, WorkspaceInfo, WorkspaceList, WorkspaceOpenworkConfig, WorkspaceType,
};
use crate::workspace::files::ensure_workspace_files;
use crate::workspace::state::{
    ensure_starter_workspace, load_workspace_state, save_workspace_state, stable_workspace_id,
    stable_workspace_id_for_openwork, stable_workspace_id_for_remote,
};
use crate::workspace::watch::{update_workspace_watch, WorkspaceWatchState};
use serde::Serialize;
use tauri::State;
use walkdir::WalkDir;
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};

#[tauri::command]
pub fn workspace_bootstrap(
    app: tauri::AppHandle,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] bootstrap");
    let mut state = load_workspace_state(&app)?;

    let starter = ensure_starter_workspace(&app)?;
    ensure_workspace_files(&starter.path, &starter.preset)?;

    if !state.workspaces.iter().any(|w| w.id == starter.id) {
        state.workspaces.push(starter.clone());
    }

    if state.active_id.trim().is_empty() {
        state.active_id = starter.id.clone();
    }

    if !state.workspaces.iter().any(|w| w.id == state.active_id) {
        state.active_id = starter.id.clone();
    }

    save_workspace_state(&app, &state)?;
    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_forget(
    app: tauri::AppHandle,
    workspace_id: String,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] forget request: {workspace_id}");
    let mut state = load_workspace_state(&app)?;
    let id = workspace_id.trim();

    if id.is_empty() {
        return Err("workspaceId is required".to_string());
    }

    let before = state.workspaces.len();
    state.workspaces.retain(|w| w.id != id);
    if before == state.workspaces.len() {
        return Err("Unknown workspaceId".to_string());
    }

    if state.active_id == id {
        state.active_id = state
            .workspaces
            .first()
            .map(|entry| entry.id.clone())
            .unwrap_or_else(|| "".to_string());
    }

    if state.workspaces.is_empty() {
        let starter = ensure_starter_workspace(&app)?;
        ensure_workspace_files(&starter.path, &starter.preset)?;
        state.active_id = starter.id.clone();
        state.workspaces.push(starter);
    }

    save_workspace_state(&app, &state)?;
    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;
    println!("[workspace] forget complete");

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_set_active(
    app: tauri::AppHandle,
    workspace_id: String,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] set_active request: {workspace_id}");
    let mut state = load_workspace_state(&app)?;
    let id = workspace_id.trim();

    if id.is_empty() {
        return Err("workspaceId is required".to_string());
    }

    if !state.workspaces.iter().any(|w| w.id == id) {
        return Err("Unknown workspaceId".to_string());
    }

    state.active_id = id.to_string();
    save_workspace_state(&app, &state)?;
    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;
    println!("[workspace] set_active complete: {id}");

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_create(
    app: tauri::AppHandle,
    folder_path: String,
    name: String,
    preset: String,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] create local request");
    let folder = folder_path.trim().to_string();
    if folder.is_empty() {
        return Err("folderPath is required".to_string());
    }

    let workspace_name = name.trim().to_string();
    if workspace_name.is_empty() {
        return Err("name is required".to_string());
    }

    let preset = preset.trim().to_string();
    let preset = if preset.is_empty() {
        "starter".to_string()
    } else {
        preset
    };

    fs::create_dir_all(&folder).map_err(|e| format!("Failed to create workspace folder: {e}"))?;

    let id = stable_workspace_id(&folder);

    ensure_workspace_files(&folder, &preset)?;

    let mut state = load_workspace_state(&app)?;

    state.workspaces.retain(|w| w.id != id);
    state.workspaces.push(WorkspaceInfo {
        id: id.clone(),
        name: workspace_name,
        path: folder,
        preset,
        workspace_type: WorkspaceType::Local,
        remote_type: None,
        base_url: None,
        directory: None,
        display_name: None,
        openwork_host_url: None,
        openwork_workspace_id: None,
        openwork_workspace_name: None,
    });

    state.active_id = id.clone();
    save_workspace_state(&app, &state)?;
    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;
    println!("[workspace] create local complete: {id}");

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_create_remote(
    app: tauri::AppHandle,
    base_url: String,
    directory: Option<String>,
    display_name: Option<String>,
    remote_type: Option<RemoteType>,
    openwork_host_url: Option<String>,
    openwork_workspace_id: Option<String>,
    openwork_workspace_name: Option<String>,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] create remote request");
    let base_url = base_url.trim().to_string();
    let remote_type = remote_type.unwrap_or_default();
    if base_url.is_empty() {
        return Err("baseUrl is required".to_string());
    }
    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
        return Err("baseUrl must start with http:// or https://".to_string());
    }

    let directory = directory
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());
    let display_name = display_name
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());

    let openwork_host_url = openwork_host_url
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());

    if remote_type == RemoteType::Openwork {
        let host_url = openwork_host_url.clone().unwrap_or_default();
        if host_url.is_empty() {
            return Err("openworkHostUrl is required for OpenWork remote".to_string());
        }
        if !host_url.starts_with("http://") && !host_url.starts_with("https://") {
            return Err("openworkHostUrl must start with http:// or https://".to_string());
        }
    }

    let id = if remote_type == RemoteType::Openwork {
        stable_workspace_id_for_openwork(
            openwork_host_url.as_deref().unwrap_or(""),
            openwork_workspace_id.as_deref(),
        )
    } else {
        stable_workspace_id_for_remote(&base_url, directory.as_deref())
    };
    let name = openwork_workspace_name
        .clone()
        .filter(|value| !value.trim().is_empty())
        .or_else(|| display_name.clone())
        .unwrap_or_else(|| {
            if remote_type == RemoteType::Openwork {
                openwork_host_url.clone().unwrap_or_else(|| base_url.clone())
            } else {
                base_url.clone()
            }
        });
    let path = directory.clone().unwrap_or_default();

    let mut state = load_workspace_state(&app)?;
    state.workspaces.retain(|w| w.id != id);
    state.workspaces.push(WorkspaceInfo {
        id: id.clone(),
        name,
        path,
        preset: "remote".to_string(),
        workspace_type: WorkspaceType::Remote,
        remote_type: Some(remote_type),
        base_url: Some(base_url),
        directory,
        display_name,
        openwork_host_url,
        openwork_workspace_id,
        openwork_workspace_name,
    });
    state.active_id = id.clone();
    save_workspace_state(&app, &state)?;
    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;
    println!("[workspace] create remote complete: {id}");

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_update_remote(
    app: tauri::AppHandle,
    workspace_id: String,
    base_url: Option<String>,
    directory: Option<String>,
    display_name: Option<String>,
    remote_type: Option<RemoteType>,
    openwork_host_url: Option<String>,
    openwork_workspace_id: Option<String>,
    openwork_workspace_name: Option<String>,
) -> Result<WorkspaceList, String> {
    println!("[workspace] update remote request: {workspace_id}");
    let mut state = load_workspace_state(&app)?;
    let id = workspace_id.trim();
    if id.is_empty() {
        return Err("workspaceId is required".to_string());
    }

    let entry = state.workspaces.iter_mut().find(|w| w.id == id);
    let Some(entry) = entry else {
        return Err("Unknown workspaceId".to_string());
    };

    if entry.workspace_type != WorkspaceType::Remote {
        return Err("workspaceId is not remote".to_string());
    }

    if let Some(next_base_url) = base_url
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
    {
        if !next_base_url.starts_with("http://") && !next_base_url.starts_with("https://") {
            return Err("baseUrl must start with http:// or https://".to_string());
        }
        entry.base_url = Some(next_base_url);
    }

    let next_directory = directory
        .as_ref()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());
    if directory.is_some() {
        entry.directory = next_directory.clone();
        entry.path = next_directory.unwrap_or_default();
    }

    if let Some(next_name) = display_name
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
    {
        entry.display_name = Some(next_name.clone());
        entry.name = next_name;
    }

    if let Some(next_remote_type) = remote_type {
        entry.remote_type = Some(next_remote_type);
    }

    if let Some(next_host_url) = openwork_host_url
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
    {
        if !next_host_url.starts_with("http://") && !next_host_url.starts_with("https://") {
            return Err("openworkHostUrl must start with http:// or https://".to_string());
        }
        entry.openwork_host_url = Some(next_host_url);
    }

    if openwork_workspace_id.is_some() {
        entry.openwork_workspace_id = openwork_workspace_id
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty());
    }

    if let Some(next_name) = openwork_workspace_name
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
    {
        entry.openwork_workspace_name = Some(next_name.clone());
        if entry.display_name.is_none() {
            entry.name = next_name;
        }
    }

    save_workspace_state(&app, &state)?;
    println!("[workspace] update remote complete: {id}");

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}

#[tauri::command]
pub fn workspace_add_authorized_root(
    _app: tauri::AppHandle,
    workspace_path: String,
    folder_path: String,
) -> Result<ExecResult, String> {
    let workspace_path = workspace_path.trim().to_string();
    let folder_path = folder_path.trim().to_string();

    if workspace_path.is_empty() {
        return Err("workspacePath is required".to_string());
    }
    if folder_path.is_empty() {
        return Err("folderPath is required".to_string());
    }

    let openwork_path = PathBuf::from(&workspace_path)
        .join(".opencode")
        .join("openwork.json");

    if let Some(parent) = openwork_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }

    let mut config: WorkspaceOpenworkConfig = if openwork_path.exists() {
        let raw = fs::read_to_string(&openwork_path)
            .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?;
        serde_json::from_str(&raw).unwrap_or_default()
    } else {
        let mut cfg = WorkspaceOpenworkConfig::default();
        if !cfg.authorized_roots.iter().any(|p| p == &workspace_path) {
            cfg.authorized_roots.push(workspace_path.clone());
        }
        cfg
    };

    if !config.authorized_roots.iter().any(|p| p == &folder_path) {
        config.authorized_roots.push(folder_path);
    }

    fs::write(
        &openwork_path,
        serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
    )
    .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: "Updated authorizedRoots".to_string(),
        stderr: String::new(),
    })
}

#[tauri::command]
pub fn workspace_openwork_read(
    _app: tauri::AppHandle,
    workspace_path: String,
) -> Result<WorkspaceOpenworkConfig, String> {
    let workspace_path = workspace_path.trim().to_string();
    if workspace_path.is_empty() {
        return Err("workspacePath is required".to_string());
    }

    let openwork_path = PathBuf::from(&workspace_path)
        .join(".opencode")
        .join("openwork.json");

    if !openwork_path.exists() {
        let mut cfg = WorkspaceOpenworkConfig::default();
        cfg.authorized_roots.push(workspace_path);
        return Ok(cfg);
    }

    let raw = fs::read_to_string(&openwork_path)
        .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?;

    serde_json::from_str::<WorkspaceOpenworkConfig>(&raw)
        .map_err(|e| format!("Failed to parse {}: {e}", openwork_path.display()))
}

#[tauri::command]
pub fn workspace_openwork_write(
    _app: tauri::AppHandle,
    workspace_path: String,
    config: WorkspaceOpenworkConfig,
) -> Result<ExecResult, String> {
    let workspace_path = workspace_path.trim().to_string();
    if workspace_path.is_empty() {
        return Err("workspacePath is required".to_string());
    }

    let openwork_path = PathBuf::from(&workspace_path)
        .join(".opencode")
        .join("openwork.json");

    if let Some(parent) = openwork_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }

    fs::write(
        &openwork_path,
        serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
    )
    .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;

    Ok(ExecResult {
        ok: true,
        status: 0,
        stdout: format!("Wrote {}", openwork_path.display()),
        stderr: String::new(),
    })
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceExportSummary {
    pub output_path: String,
    pub included: usize,
    pub excluded: Vec<String>,
}

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

fn normalize_zip_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn is_secret_name(name: &str) -> bool {
    let lower = name.to_lowercase();
    if lower == ".env" || lower.starts_with(".env.") {
        return true;
    }
    if lower == "credentials.json"
        || lower == "credentials.yml"
        || lower == "credentials.yaml"
    {
        return true;
    }
    if lower.ends_with(".key")
        || lower.ends_with(".pem")
        || lower.ends_with(".p12")
        || lower.ends_with(".pfx")
    {
        return true;
    }
    false
}

fn should_exclude(path: &Path) -> bool {
    let name = path
        .file_name()
        .and_then(|entry| entry.to_str())
        .unwrap_or("");
    is_secret_name(name)
}

fn collect_workspace_entries(
    workspace_root: &Path,
) -> Result<(Vec<(PathBuf, String)>, Vec<String>), String> {
    let mut entries: Vec<(PathBuf, String)> = Vec::new();
    let mut excluded: Vec<String> = Vec::new();

    let config_path = workspace_root.join("opencode.json");
    if config_path.exists() && config_path.is_file() {
        if should_exclude(&config_path) {
            excluded.push("opencode.json".to_string());
        } else {
            entries.push((config_path, "opencode.json".to_string()));
        }
    }

    let opencode_dir = workspace_root.join(".opencode");
    if opencode_dir.exists() {
        for entry in WalkDir::new(&opencode_dir) {
            let entry = entry.map_err(|e| e.to_string())?;
            if !entry.file_type().is_file() {
                continue;
            }
            let absolute = entry.path().to_path_buf();
            let rel = absolute
                .strip_prefix(workspace_root)
                .map_err(|e| format!("Failed to compute relative path: {e}"))?;
            let rel_str = normalize_zip_path(rel);
            if should_exclude(&absolute) {
                if !excluded.contains(&rel_str) {
                    excluded.push(rel_str);
                }
                continue;
            }
            entries.push((absolute, rel_str));
        }
    }

    Ok((entries, excluded))
}

#[tauri::command]
pub fn workspace_export_config(
    app: tauri::AppHandle,
    workspace_id: String,
    output_path: String,
) -> Result<WorkspaceExportSummary, String> {
    let workspace_id = workspace_id.trim().to_string();
    if workspace_id.is_empty() {
        return Err("workspaceId is required".to_string());
    }
    let output_path = output_path.trim().to_string();
    if output_path.is_empty() {
        return Err("outputPath is required".to_string());
    }

    let state = load_workspace_state(&app)?;
    let workspace = state
        .workspaces
        .iter()
        .find(|w| w.id == workspace_id)
        .ok_or_else(|| "Unknown workspaceId".to_string())?;

    if workspace.workspace_type != WorkspaceType::Local {
        return Err("Workspace export is only supported for local workspaces".to_string());
    }

    let workspace_root = PathBuf::from(&workspace.path);
    if !workspace_root.exists() {
        return Err(format!("Workspace path not found: {}", workspace_root.display()));
    }

    let output_path = PathBuf::from(&output_path);
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create export folder {}: {e}", parent.display()))?;
    }

    let (entries, excluded_paths) = collect_workspace_entries(&workspace_root)?;
    if entries.is_empty() {
        return Err("No workspace config files found to export".to_string());
    }

    let file = fs::File::create(&output_path)
        .map_err(|e| format!("Failed to create {}: {e}", output_path.display()))?;
    let mut zip = ZipWriter::new(file);
    let options = FileOptions::default().compression_method(CompressionMethod::Deflated);
    let mut included_paths: Vec<String> = Vec::new();

    for (src, rel) in entries {
        let mut input = fs::File::open(&src)
            .map_err(|e| format!("Failed to read {}: {e}", src.display()))?;
        zip.start_file(rel.clone(), options)
            .map_err(|e| format!("Failed to add {}: {e}", rel))?;
        let mut buffer = Vec::new();
        input
            .read_to_end(&mut buffer)
            .map_err(|e| format!("Failed to read {}: {e}", src.display()))?;
        zip.write_all(&buffer)
            .map_err(|e| format!("Failed to write {}: {e}", src.display()))?;
        included_paths.push(rel);
    }

    let included_count = included_paths.len();
    let excluded_summary = excluded_paths.clone();
    let manifest = serde_json::json!({
        "version": 1,
        "createdAtMs": now_ms(),
        "workspace": {
            "id": workspace.id.clone(),
            "name": workspace.name.clone(),
            "path": workspace.path.clone()
        },
        "included": included_paths,
        "excluded": excluded_paths,
    });
    let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
    zip.start_file("manifest.json", options)
        .map_err(|e| format!("Failed to add manifest: {e}"))?;
    zip.write_all(manifest_json.as_bytes())
        .map_err(|e| format!("Failed to write manifest: {e}"))?;

    zip.finish()
        .map_err(|e| format!("Failed to finalize export: {e}"))?;

    Ok(WorkspaceExportSummary {
        output_path: output_path.to_string_lossy().to_string(),
        included: included_count,
        excluded: excluded_summary,
    })
}

#[tauri::command]
pub fn workspace_import_config(
    app: tauri::AppHandle,
    archive_path: String,
    target_dir: String,
    name: Option<String>,
    watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
    let archive_path = archive_path.trim().to_string();
    if archive_path.is_empty() {
        return Err("archivePath is required".to_string());
    }
    let target_dir = target_dir.trim().to_string();
    if target_dir.is_empty() {
        return Err("targetDir is required".to_string());
    }

    let target_path = PathBuf::from(&target_dir);
    if target_path.exists() {
        let mut entries = fs::read_dir(&target_path)
            .map_err(|e| format!("Failed to read {}: {e}", target_path.display()))?;
        if entries.next().is_some() {
            return Err("Target folder must be empty".to_string());
        }
    }

    fs::create_dir_all(&target_path)
        .map_err(|e| format!("Failed to create {}: {e}", target_path.display()))?;

    let file = fs::File::open(&archive_path)
        .map_err(|e| format!("Failed to open {}: {e}", archive_path))?;
    let mut archive = ZipArchive::new(file)
        .map_err(|e| format!("Failed to read archive: {e}"))?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
        let name = entry.name().to_string();
        if name == "manifest.json" {
            continue;
        }
        let entry_path = Path::new(&name);
        if entry_path.components().any(|component| match component {
            std::path::Component::ParentDir
            | std::path::Component::RootDir
            | std::path::Component::Prefix(_) => true,
            _ => false,
        }) {
            return Err("Archive contains an unsafe path".to_string());
        }
        if !(name == "opencode.json" || name.starts_with(".opencode/")) {
            continue;
        }
        if let Some(file_name) = entry_path.file_name().and_then(|entry| entry.to_str()) {
            if is_secret_name(file_name) {
                continue;
            }
        }
        let out_path = target_path.join(Path::new(&name));
        if let Some(parent) = out_path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
        }
        if entry.name().ends_with('/') {
            fs::create_dir_all(&out_path)
                .map_err(|e| format!("Failed to create {}: {e}", out_path.display()))?;
            continue;
        }
        let mut buffer = Vec::new();
        entry.read_to_end(&mut buffer)
            .map_err(|e| format!("Failed to read archive entry: {e}"))?;
        fs::write(&out_path, buffer)
            .map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
    }

    let opencode_dir = target_path.join(".opencode");
    if !opencode_dir.exists() {
        return Err("Archive is missing .opencode config".to_string());
    }

    let openwork_path = target_path.join(".opencode").join("openwork.json");
    let mut preset = "starter".to_string();
    let mut workspace_name = name.clone().filter(|value| !value.trim().is_empty());

    if openwork_path.exists() {
        let raw = fs::read_to_string(&openwork_path)
            .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?;
        if let Ok(mut config) = serde_json::from_str::<WorkspaceOpenworkConfig>(&raw) {
            config.authorized_roots = vec![target_dir.clone()];
            if let Some(workspace) = &config.workspace {
                if workspace_name.is_none() {
                    workspace_name = workspace.name.clone().filter(|value| !value.trim().is_empty());
                }
                if let Some(next_preset) = &workspace.preset {
                    if !next_preset.trim().is_empty() {
                        preset = next_preset.clone();
                    }
                }
            }
            fs::write(
                &openwork_path,
                serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
            )
            .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;
        }
    } else {
        let config = WorkspaceOpenworkConfig::new(&target_dir, &preset, now_ms());
        if let Some(parent) = openwork_path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
        }
        fs::write(
            &openwork_path,
            serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
        )
        .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;
    }

    let name = workspace_name
        .unwrap_or_else(|| {
            target_path
                .file_name()
                .and_then(|entry| entry.to_str())
                .unwrap_or("Workspace")
                .to_string()
        })
        .trim()
        .to_string();

    let id = stable_workspace_id(&target_dir);

    let mut state = load_workspace_state(&app)?;
    state.workspaces.retain(|w| w.id != id);
    state.workspaces.push(WorkspaceInfo {
        id: id.clone(),
        name,
        path: target_dir.clone(),
        preset,
        workspace_type: WorkspaceType::Local,
        remote_type: None,
        base_url: None,
        directory: None,
        display_name: None,
        openwork_host_url: None,
        openwork_workspace_id: None,
        openwork_workspace_name: None,
    });
    state.active_id = id.clone();
    save_workspace_state(&app, &state)?;

    let active_workspace = state.workspaces.iter().find(|w| w.id == state.active_id);
    update_workspace_watch(&app, watch_state, active_workspace)?;

    Ok(WorkspaceList {
        active_id: state.active_id,
        workspaces: state.workspaces,
    })
}



================================================
FILE: packages/desktop/src-tauri/src/engine/doctor.rs
================================================
use std::ffi::OsStr;
use std::path::Path;

use crate::engine::paths::resolve_opencode_executable;
use crate::platform::command_for_program;
use crate::utils::truncate_output;

pub fn opencode_version(program: &OsStr) -> Option<String> {
    let output = command_for_program(Path::new(program))
        .arg("--version")
        .output()
        .ok()?;
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();

    if !stdout.is_empty() {
        return Some(stdout);
    }
    if !stderr.is_empty() {
        return Some(stderr);
    }

    None
}

pub fn opencode_serve_help(program: &OsStr) -> (bool, Option<i32>, Option<String>, Option<String>) {
    match command_for_program(Path::new(program))
        .arg("serve")
        .arg("--help")
        .output()
    {
        Ok(output) => {
            let status = output.status.code();
            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            let ok = output.status.success();

            let stdout = if stdout.is_empty() {
                None
            } else {
                Some(truncate_output(&stdout, 4000))
            };
            let stderr = if stderr.is_empty() {
                None
            } else {
                Some(truncate_output(&stderr, 4000))
            };

            (ok, status, stdout, stderr)
        }
        Err(_) => (false, None, None, None),
    }
}

pub fn resolve_sidecar_candidate(
    prefer_sidecar: bool,
    resource_dir: Option<&Path>,
    current_bin_dir: Option<&Path>,
) -> (Option<std::path::PathBuf>, Vec<String>) {
    if !prefer_sidecar {
        return (None, Vec::new());
    }

    let mut notes = Vec::new();

    let mut candidates = Vec::new();

    if let Some(current_bin_dir) = current_bin_dir {
        candidates.push(current_bin_dir.join(crate::engine::paths::opencode_executable_name()));
    }

    if let Some(resource_dir) = resource_dir {
        candidates.push(
            resource_dir
                .join("sidecars")
                .join(crate::engine::paths::opencode_executable_name()),
        );
        candidates.push(resource_dir.join(crate::engine::paths::opencode_executable_name()));
    }

    candidates.push(
        std::path::PathBuf::from("src-tauri/sidecars")
            .join(crate::engine::paths::opencode_executable_name()),
    );

    for candidate in candidates {
        if candidate.is_file() {
            notes.push(format!("Using bundled sidecar: {}", candidate.display()));
            return (Some(candidate), notes);
        }

        notes.push(format!("Sidecar missing: {}", candidate.display()));
    }

    (None, notes)
}

pub fn resolve_engine_path(
    prefer_sidecar: bool,
    resource_dir: Option<&Path>,
    current_bin_dir: Option<&Path>,
) -> (Option<std::path::PathBuf>, bool, Vec<String>) {
    let (sidecar, mut notes) =
        resolve_sidecar_candidate(prefer_sidecar, resource_dir, current_bin_dir);
    let (resolved, in_path, more_notes) = match sidecar {
        Some(path) => (Some(path), false, Vec::new()),
        None => resolve_opencode_executable(),
    };

    notes.extend(more_notes);
    (resolved, in_path, notes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(not(windows))]
    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
        use std::time::{SystemTime, UNIX_EPOCH};

        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);

        let mut dir = std::env::temp_dir();
        dir.push(format!("openwork-{name}-{}-{}", std::process::id(), nanos));
        dir
    }

    #[test]
    #[cfg(not(windows))]
    fn resolves_sidecar_from_current_binary_dir() {
        let dir = unique_temp_dir("sidecar-test");
        std::fs::create_dir_all(&dir).expect("create temp dir");

        let sidecar_path = dir.join(crate::engine::paths::opencode_executable_name());
        std::fs::write(&sidecar_path, b"").expect("create fake sidecar");

        let (resolved, notes) = resolve_sidecar_candidate(true, None, Some(dir.as_path()));
        assert_eq!(resolved.as_ref(), Some(&sidecar_path));
        assert!(
            notes
                .iter()
                .any(|note| note.contains("Using bundled sidecar")),
            "missing success note: {:?}",
            notes
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    #[cfg(not(windows))]
    fn resolve_engine_path_prefers_sidecar() {
        let dir = unique_temp_dir("engine-path-test");
        std::fs::create_dir_all(&dir).expect("create temp dir");

        let sidecar_path = dir.join(crate::engine::paths::opencode_executable_name());
        std::fs::write(&sidecar_path, b"").expect("create fake sidecar");

        let (resolved, in_path, _notes) = resolve_engine_path(true, None, Some(dir.as_path()));
        assert_eq!(resolved.as_ref(), Some(&sidecar_path));
        assert!(!in_path);

        let _ = std::fs::remove_dir_all(&dir);
    }
}



================================================
FILE: packages/desktop/src-tauri/src/engine/manager.rs
================================================
use std::sync::{Arc, Mutex};

use tauri_plugin_shell::process::CommandChild;

use crate::types::EngineInfo;

#[derive(Default)]
pub struct EngineManager {
    pub inner: Arc<Mutex<EngineState>>,
}

#[derive(Default)]
pub struct EngineState {
    pub child: Option<CommandChild>,
    pub child_exited: bool,
    pub project_dir: Option<String>,
    pub hostname: Option<String>,
    pub port: Option<u16>,
    pub base_url: Option<String>,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

impl EngineManager {
    pub fn snapshot_locked(state: &mut EngineState) -> EngineInfo {
        let (running, pid) = match state.child.as_ref() {
            None => (false, None),
            Some(child) if state.child_exited => {
                state.child = None;
                (false, None)
            }
            Some(child) => (true, Some(child.pid())),
        };

        EngineInfo {
            running,
            base_url: state.base_url.clone(),
            project_dir: state.project_dir.clone(),
            hostname: state.hostname.clone(),
            port: state.port,
            pid,
            last_stdout: state.last_stdout.clone(),
            last_stderr: state.last_stderr.clone(),
        }
    }

    pub fn stop_locked(state: &mut EngineState) {
        if let Some(child) = state.child.take() {
            let _ = child.kill();
        }
        state.child_exited = true;
        state.base_url = None;
        state.project_dir = None;
        state.hostname = None;
        state.port = None;
        state.last_stdout = None;
        state.last_stderr = None;
    }
}



================================================
FILE: packages/desktop/src-tauri/src/engine/mod.rs
================================================
pub mod doctor;
pub mod manager;
pub mod paths;
pub mod spawn;



================================================
FILE: packages/desktop/src-tauri/src/engine/paths.rs
================================================
use std::path::PathBuf;

use crate::paths::{home_dir, resolve_in_path};

#[cfg(windows)]
const OPENCODE_EXECUTABLE: &str = "opencode.exe";

#[cfg(windows)]
const OPENCODE_CMD: &str = "opencode.cmd";

#[cfg(not(windows))]
const OPENCODE_EXECUTABLE: &str = "opencode";

pub fn opencode_executable_name() -> &'static str {
    OPENCODE_EXECUTABLE
}

pub fn candidate_opencode_paths() -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    if let Some(home) = home_dir() {
        candidates.push(home.join(".opencode").join("bin").join(OPENCODE_EXECUTABLE));
    }

    #[cfg(windows)]
    {
        if let Some(app_data) = std::env::var_os("APPDATA") {
            let base = PathBuf::from(app_data).join("npm");
            candidates.push(base.join(OPENCODE_EXECUTABLE));
            candidates.push(base.join(OPENCODE_CMD));
        }

        if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
            let base = PathBuf::from(&local_app_data);
            let npm = base.join("npm");
            candidates.push(npm.join(OPENCODE_EXECUTABLE));
            candidates.push(npm.join(OPENCODE_CMD));
            candidates.push(base.join("OpenCode").join(OPENCODE_EXECUTABLE));
        }

        if let Some(home) = home_dir() {
            let scoop = home.join("scoop").join("shims");
            candidates.push(scoop.join(OPENCODE_EXECUTABLE));
            candidates.push(scoop.join(OPENCODE_CMD));
        }

        candidates
            .push(PathBuf::from("C:\\ProgramData\\chocolatey\\bin").join(OPENCODE_EXECUTABLE));
        candidates.push(PathBuf::from("C:\\ProgramData\\chocolatey\\bin").join(OPENCODE_CMD));
    }

    #[cfg(not(windows))]
    {
        candidates.push(PathBuf::from("/opt/homebrew/bin").join(OPENCODE_EXECUTABLE));
        candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE));
        candidates.push(PathBuf::from("/usr/bin").join(OPENCODE_EXECUTABLE));
        candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE));
    }

    candidates
}

pub fn resolve_opencode_executable() -> (Option<PathBuf>, bool, Vec<String>) {
    let mut notes = Vec::new();

    if let Ok(custom) = std::env::var("OPENCODE_BIN_PATH") {
        let custom = custom.trim();
        if !custom.is_empty() {
            let candidate = PathBuf::from(custom);
            if candidate.is_file() {
                notes.push(format!("Using OPENCODE_BIN_PATH: {}", candidate.display()));
                return (Some(candidate), false, notes);
            }
            notes.push(format!(
                "OPENCODE_BIN_PATH set but missing: {}",
                candidate.display()
            ));
        }
    }

    if let Some(path) = resolve_in_path(OPENCODE_EXECUTABLE) {
        notes.push(format!("Found in PATH: {}", path.display()));
        return (Some(path), true, notes);
    }

    #[cfg(windows)]
    {
        if let Some(path) = resolve_in_path(OPENCODE_CMD) {
            notes.push(format!("Found in PATH: {}", path.display()));
            return (Some(path), true, notes);
        }
    }

    notes.push("Not found on PATH".to_string());

    for candidate in candidate_opencode_paths() {
        if candidate.is_file() {
            notes.push(format!("Found at {}", candidate.display()));
            return (Some(candidate), false, notes);
        }

        notes.push(format!("Missing: {}", candidate.display()));
    }

    (None, false, notes)
}



================================================
FILE: packages/desktop/src-tauri/src/engine/spawn.rs
================================================
use std::path::Path;

use tauri::AppHandle;
use tauri::async_runtime::Receiver;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

use crate::paths::{candidate_xdg_config_dirs, candidate_xdg_data_dirs, maybe_infer_xdg_home};

pub fn find_free_port() -> Result<u16, String> {
    let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| e.to_string())?;
    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
    Ok(port)
}

pub fn build_engine_args(bind_host: &str, port: u16) -> Vec<String> {
    vec![
        "serve".to_string(),
        "--hostname".to_string(),
        bind_host.to_string(),
        "--port".to_string(),
        port.to_string(),
        "--cors".to_string(),
        "http://localhost:5173".to_string(),
        "--cors".to_string(),
        "tauri://localhost".to_string(),
        "--cors".to_string(),
        "http://tauri.localhost".to_string(),
    ]
}

pub fn spawn_engine(
    app: &AppHandle,
    program: &Path,
    hostname: &str,
    port: u16,
    project_dir: &str,
    use_sidecar: bool,
) -> Result<(Receiver<CommandEvent>, CommandChild), String> {
    let args = build_engine_args(hostname, port);

    let command = if use_sidecar {
        app
            .shell()
            .sidecar("opencode")
            .map_err(|e| format!("Failed to locate bundled OpenCode sidecar: {e}"))?
    } else {
        app.shell().command(program)
    };

    let mut command = command.args(args).current_dir(project_dir);

    if let Some(xdg_data_home) = maybe_infer_xdg_home(
        "XDG_DATA_HOME",
        candidate_xdg_data_dirs(),
        Path::new("opencode/auth.json"),
    ) {
        command = command.env("XDG_DATA_HOME", xdg_data_home);
    }

    let xdg_config_home = maybe_infer_xdg_home(
        "XDG_CONFIG_HOME",
        candidate_xdg_config_dirs(),
        Path::new("opencode/opencode.jsonc"),
    )
    .or_else(|| {
        maybe_infer_xdg_home(
            "XDG_CONFIG_HOME",
            candidate_xdg_config_dirs(),
            Path::new("opencode/opencode.json"),
        )
    });

    if let Some(xdg_config_home) = xdg_config_home {
        command = command.env("XDG_CONFIG_HOME", xdg_config_home);
    }

    command = command.env("OPENCODE_CLIENT", "openwork");
    command = command.env("OPENWORK", "1");

    command
        .spawn()
        .map_err(|e| format!("Failed to start opencode: {e}"))
}



================================================
FILE: packages/desktop/src-tauri/src/openwork_server/manager.rs
================================================
use std::sync::{Arc, Mutex};

use tauri_plugin_shell::process::CommandChild;

use crate::types::OpenworkServerInfo;

#[derive(Default)]
pub struct OpenworkServerManager {
    pub inner: Arc<Mutex<OpenworkServerState>>,
}

#[derive(Default)]
pub struct OpenworkServerState {
    pub child: Option<CommandChild>,
    pub child_exited: bool,
    pub host: Option<String>,
    pub port: Option<u16>,
    pub base_url: Option<String>,
    pub connect_url: Option<String>,
    pub mdns_url: Option<String>,
    pub lan_url: Option<String>,
    pub client_token: Option<String>,
    pub host_token: Option<String>,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

impl OpenworkServerManager {
    pub fn snapshot_locked(state: &mut OpenworkServerState) -> OpenworkServerInfo {
        let (running, pid) = match state.child.as_ref() {
            None => (false, None),
            Some(child) if state.child_exited => {
                state.child = None;
                (false, None)
            }
            Some(child) => (true, Some(child.pid())),
        };

        OpenworkServerInfo {
            running,
            host: state.host.clone(),
            port: state.port,
            base_url: state.base_url.clone(),
            connect_url: state.connect_url.clone(),
            mdns_url: state.mdns_url.clone(),
            lan_url: state.lan_url.clone(),
            client_token: state.client_token.clone(),
            host_token: state.host_token.clone(),
            pid,
            last_stdout: state.last_stdout.clone(),
            last_stderr: state.last_stderr.clone(),
        }
    }

    pub fn stop_locked(state: &mut OpenworkServerState) {
        if let Some(child) = state.child.take() {
            let _ = child.kill();
        }
        state.child_exited = true;
        state.host = None;
        state.port = None;
        state.base_url = None;
        state.connect_url = None;
        state.mdns_url = None;
        state.lan_url = None;
        state.client_token = None;
        state.host_token = None;
        state.last_stdout = None;
        state.last_stderr = None;
    }
}



================================================
FILE: packages/desktop/src-tauri/src/openwork_server/mod.rs
================================================
use gethostname::gethostname;
use local_ip_address::local_ip;
use tauri::AppHandle;
use tauri_plugin_shell::process::CommandEvent;
use uuid::Uuid;

use crate::types::OpenworkServerInfo;
use crate::utils::truncate_output;

pub mod manager;
pub mod spawn;

use manager::OpenworkServerManager;
use spawn::{resolve_openwork_port, spawn_openwork_server};

fn generate_token() -> String {
    Uuid::new_v4().to_string()
}

fn build_urls(port: u16) -> (Option<String>, Option<String>, Option<String>) {
    let hostname = gethostname().to_string_lossy().trim().to_string();
    let mdns_url = if hostname.is_empty() {
        None
    } else {
        let trimmed = hostname.trim_end_matches(".local");
        Some(format!("http://{trimmed}.local:{port}"))
    };

    let lan_url = local_ip()
        .ok()
        .map(|ip| format!("http://{ip}:{port}"));

    let connect_url = lan_url.clone().or(mdns_url.clone());

    (connect_url, mdns_url, lan_url)
}

pub fn resolve_connect_url(port: u16) -> Option<String> {
    let (connect_url, _mdns_url, _lan_url) = build_urls(port);
    connect_url
}

pub fn start_openwork_server(
    app: &AppHandle,
    manager: &OpenworkServerManager,
    workspace_path: &str,
    opencode_base_url: Option<&str>,
) -> Result<OpenworkServerInfo, String> {
    let mut state = manager.inner.lock().map_err(|_| "openwork server mutex poisoned".to_string())?;
    OpenworkServerManager::stop_locked(&mut state);

    let host = "0.0.0.0".to_string();
    let port = resolve_openwork_port()?;
    let client_token = generate_token();
    let host_token = generate_token();

    let (mut rx, child) = spawn_openwork_server(
        app,
        &host,
        port,
        workspace_path,
        &client_token,
        &host_token,
        opencode_base_url,
        Some(workspace_path),
    )?;

    state.child = Some(child);
    state.child_exited = false;
    state.host = Some(host.clone());
    state.port = Some(port);
    state.base_url = Some(format!("http://127.0.0.1:{port}"));
    let (connect_url, mdns_url, lan_url) = build_urls(port);
    state.connect_url = connect_url;
    state.mdns_url = mdns_url;
    state.lan_url = lan_url;
    state.client_token = Some(client_token);
    state.host_token = Some(host_token);
    state.last_stdout = None;
    state.last_stderr = None;

    let state_handle = manager.inner.clone();

    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state.last_stdout.as_deref().unwrap_or_default().to_string() + &line;
                        state.last_stdout = Some(truncate_output(&next, 8000));
                    }
                }
                CommandEvent::Stderr(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes).to_string();
                    if let Ok(mut state) = state_handle.try_lock() {
                        let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &line;
                        state.last_stderr = Some(truncate_output(&next, 8000));
                    }
                }
                CommandEvent::Terminated(payload) => {
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                        if let Some(code) = payload.code {
                            let next = format!("OpenWork server exited (code {code}).");
                            state.last_stderr = Some(truncate_output(&next, 8000));
                        }
                    }
                }
                CommandEvent::Error(message) => {
                    if let Ok(mut state) = state_handle.try_lock() {
                        state.child_exited = true;
                        let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &message;
                        state.last_stderr = Some(truncate_output(&next, 8000));
                    }
                }
                _ => {}
            }
        }
    });

    Ok(OpenworkServerManager::snapshot_locked(&mut state))
}



================================================
FILE: packages/desktop/src-tauri/src/openwork_server/spawn.rs
================================================
use std::net::TcpListener;
use std::path::Path;

use tauri::AppHandle;
use tauri::async_runtime::Receiver;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

const DEFAULT_OPENWORK_PORT: u16 = 8787;

pub fn resolve_openwork_port() -> Result<u16, String> {
    if TcpListener::bind(("0.0.0.0", DEFAULT_OPENWORK_PORT)).is_ok() {
        return Ok(DEFAULT_OPENWORK_PORT);
    }
    let listener = TcpListener::bind(("0.0.0.0", 0)).map_err(|e| e.to_string())?;
    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
    Ok(port)
}

pub fn build_openwork_args(
    host: &str,
    port: u16,
    workspace_path: &str,
    token: &str,
    host_token: &str,
    opencode_base_url: Option<&str>,
    opencode_directory: Option<&str>,
) -> Vec<String> {
    let mut args = vec![
        "--host".to_string(),
        host.to_string(),
        "--port".to_string(),
        port.to_string(),
        "--token".to_string(),
        token.to_string(),
        "--host-token".to_string(),
        host_token.to_string(),
        "--workspace".to_string(),
        workspace_path.to_string(),
        "--cors".to_string(),
        "http://localhost:5173".to_string(),
        "--cors".to_string(),
        "tauri://localhost".to_string(),
        "--cors".to_string(),
        "http://tauri.localhost".to_string(),
    ];

    if cfg!(debug_assertions) {
        args.push("--cors".to_string());
        args.push("*".to_string());
    }

    if let Some(base_url) = opencode_base_url {
        if !base_url.trim().is_empty() {
            args.push("--opencode-base-url".to_string());
            args.push(base_url.to_string());
        }
    }

    if let Some(directory) = opencode_directory {
        if !directory.trim().is_empty() {
            args.push("--opencode-directory".to_string());
            args.push(directory.to_string());
        }
    }

    args
}

pub fn spawn_openwork_server(
    app: &AppHandle,
    host: &str,
    port: u16,
    workspace_path: &str,
    token: &str,
    host_token: &str,
    opencode_base_url: Option<&str>,
    opencode_directory: Option<&str>,
) -> Result<(Receiver<CommandEvent>, CommandChild), String> {
    let command = match app.shell().sidecar("openwork-server") {
        Ok(command) => command,
        Err(_) => app.shell().command("openwork-server"),
    };

    let args = build_openwork_args(
        host,
        port,
        workspace_path,
        token,
        host_token,
        opencode_base_url,
        opencode_directory,
    );
    command
        .args(args)
        .current_dir(Path::new(workspace_path))
        .spawn()
        .map_err(|e| format!("Failed to start OpenWork server: {e}"))
}



================================================
FILE: packages/desktop/src-tauri/src/owpenbot/manager.rs
================================================
use std::sync::{Arc, Mutex};

use tauri_plugin_shell::process::CommandChild;

use crate::types::OwpenbotInfo;

#[derive(Default)]
pub struct OwpenbotManager {
    pub inner: Arc<Mutex<OwpenbotState>>,
}

#[derive(Default)]
pub struct OwpenbotState {
    pub child: Option<CommandChild>,
    pub child_exited: bool,
    pub workspace_path: Option<String>,
    pub opencode_url: Option<String>,
    pub qr_data: Option<String>,
    pub whatsapp_linked: bool,
    pub telegram_configured: bool,
    pub last_stdout: Option<String>,
    pub last_stderr: Option<String>,
}

impl OwpenbotManager {
    pub fn snapshot_locked(state: &mut OwpenbotState) -> OwpenbotInfo {
        let (running, pid) = match state.child.as_ref() {
            None => (false, None),
            Some(_child) if state.child_exited => {
                state.child = None;
                (false, None)
            }
            Some(child) => (true, Some(child.pid())),
        };

        OwpenbotInfo {
            running,
            workspace_path: state.workspace_path.clone(),
            opencode_url: state.opencode_url.clone(),
            qr_data: state.qr_data.clone(),
            whatsapp_linked: state.whatsapp_linked,
            telegram_configured: state.telegram_configured,
            pid,
            last_stdout: state.last_stdout.clone(),
            last_stderr: state.last_stderr.clone(),
        }
    }

    pub fn stop_locked(state: &mut OwpenbotState) {
        if let Some(child) = state.child.take() {
            let _ = child.kill();
        }
        state.child_exited = true;
        state.workspace_path = None;
        state.opencode_url = None;
        state.qr_data = None;
        state.whatsapp_linked = false;
        state.telegram_configured = false;
        state.last_stdout = None;
        state.last_stderr = None;
    }
}



================================================
FILE: packages/desktop/src-tauri/src/owpenbot/mod.rs
================================================
pub mod manager;
pub mod spawn;



================================================
FILE: packages/desktop/src-tauri/src/owpenbot/spawn.rs
================================================
use std::path::Path;

use tauri::AppHandle;
use tauri::async_runtime::Receiver;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

pub fn build_owpenbot_args(
    workspace_path: &str,
    opencode_url: Option<&str>,
) -> Vec<String> {
    let mut args = vec!["start".to_string(), workspace_path.to_string()];

    if let Some(url) = opencode_url {
        let trimmed = url.trim();
        if !trimmed.is_empty() {
            args.push("--opencode-url".to_string());
            args.push(trimmed.to_string());
        }
    }

    args
}

pub fn spawn_owpenbot(
    app: &AppHandle,
    workspace_path: &str,
    opencode_url: Option<&str>,
) -> Result<(Receiver<CommandEvent>, CommandChild), String> {
    let command = match app.shell().sidecar("owpenbot") {
        Ok(command) => command,
        Err(_) => app.shell().command("owpenbot"),
    };

    let args = build_owpenbot_args(workspace_path, opencode_url);
    
    command
        .args(args)
        .current_dir(Path::new(workspace_path))
        .spawn()
        .map_err(|e| format!("Failed to start owpenbot: {e}"))
}



================================================
FILE: packages/desktop/src-tauri/src/platform/mod.rs
================================================
#[cfg(not(windows))]
mod unix;
#[cfg(windows)]
mod windows;

#[cfg(not(windows))]
pub use unix::{command_for_program, configure_hidden};
#[cfg(windows)]
pub use windows::{command_for_program, configure_hidden};



================================================
FILE: packages/desktop/src-tauri/src/platform/unix.rs
================================================
use std::path::Path;
use std::process::Command;

pub fn command_for_program(program: &Path) -> Command {
    Command::new(program)
}

pub fn configure_hidden(_command: &mut Command) {}



================================================
FILE: packages/desktop/src-tauri/src/platform/windows.rs
================================================
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;

const CREATE_NO_WINDOW: u32 = 0x08000000;

pub fn command_for_program(program: &Path) -> Command {
    if program
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case("cmd"))
        .unwrap_or(false)
    {
        let mut command = Command::new("cmd");
        command
            .arg("/C")
            .arg(program)
            .creation_flags(CREATE_NO_WINDOW);
        return command;
    }

    let mut command = Command::new(program);
    command.creation_flags(CREATE_NO_WINDOW);
    command
}

pub fn configure_hidden(command: &mut Command) {
    command.creation_flags(CREATE_NO_WINDOW);
}



================================================
FILE: packages/desktop/src-tauri/src/workspace/commands.rs
================================================
use crate::types::OpencodeCommand;

pub fn sanitize_command_name(raw: &str) -> Option<String> {
    let trimmed = raw.trim().trim_start_matches('/');
    if trimmed.is_empty() {
        return None;
    }

    let mut out = String::new();
    for ch in trimmed.chars() {
        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
            out.push(ch);
        }
    }

    if out.is_empty() {
        return None;
    }

    Some(out)
}

pub fn serialize_command_frontmatter(command: &OpencodeCommand) -> Result<String, String> {
    fn escape_yaml_scalar(value: &str) -> String {
        let mut out = String::with_capacity(value.len() + 2);
        out.push('"');
        for ch in value.chars() {
            match ch {
                '\\' => out.push_str("\\\\"),
                '"' => out.push_str("\\\""),
                '\n' => out.push_str("\\n"),
                '\r' => out.push_str("\\r"),
                '\t' => out.push_str("\\t"),
                _ => out.push(ch),
            }
        }
        out.push('"');
        out
    }

    let template = command.template.trim();
    if template.is_empty() {
        return Err("command.template is required".to_string());
    }

    let mut out = String::new();
    out.push_str("---\n");
    if let Some(description) = command
        .description
        .as_ref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
    {
        out.push_str(&format!(
            "description: {}\n",
            escape_yaml_scalar(description)
        ));
    }
    if let Some(agent) = command
        .agent
        .as_ref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
    {
        out.push_str(&format!("agent: {}\n", escape_yaml_scalar(agent)));
    }
    if let Some(model) = command
        .model
        .as_ref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
    {
        out.push_str(&format!("model: {}\n", escape_yaml_scalar(model)));
    }
    if command.subtask.unwrap_or(false) {
        out.push_str("subtask: true\n");
    }
    out.push_str("---\n\n");
    out.push_str(template);
    out.push('\n');
    Ok(out)
}



================================================
FILE: packages/desktop/src-tauri/src/workspace/files.rs
================================================
use std::fs;
use std::collections::HashSet;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};

use zip::ZipArchive;

use crate::types::{OpencodeCommand, WorkspaceOpenworkConfig};
use crate::utils::now_ms;
use crate::workspace::commands::{sanitize_command_name, serialize_command_frontmatter};

pub fn merge_plugins(existing: Vec<String>, required: &[&str]) -> Vec<String> {
    let mut out = existing;
    for plugin in required {
        if !out.iter().any(|entry| entry == plugin) {
            out.push(plugin.to_string());
        }
    }
    out
}

fn seed_workspace_guide(skill_root: &PathBuf) -> Result<(), String> {
    let guide_dir = skill_root.join("workspace-guide");
    if guide_dir.exists() {
        return Ok(());
    }

    fs::create_dir_all(&guide_dir)
        .map_err(|e| format!("Failed to create {}: {e}", guide_dir.display()))?;

    let doc = r#"---
name: workspace-guide
description: Workspace guide to introduce OpenWork and onboard new users.
---

# Welcome to OpenWork

Hi, I'm Ben and this is OpenWork. It's an open-source alternative to Claude's cowork. It helps you work on your files with AI and automate the mundane tasks so you don't have to.

Before we start, use the question tool to ask:
"Are you more technical or non-technical? I'll tailor the explanation."

## If the person is non-technical
OpenWork feels like a chat app, but it can safely work with the files you allow. Put files in this workspace and I can summarize them, create new ones, or help organize them.

Try:
- "Summarize the files in this workspace."
- "Create a checklist for my week."
- "Draft a short summary from this document."

## Skills and plugins (simple)
Skills add new capabilities. Plugins add advanced features like scheduling or browser automation. We can add them later when you're ready.

## If the person is technical
OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here.

Most reliable setup today:
1) Install OpenCode from opencode.ai
2) Configure providers there (models and API keys)
3) Come back to OpenWork and start a session

Skills:
- Install from the Skills tab, or add them to this workspace.
- Docs: https://opencode.ai/docs/skills

Plugins:
- Configure in opencode.json or use the Plugins tab.
- Docs: https://opencode.ai/docs/plugins/

MCP servers:
- Add external tools via opencode.json.
- Docs: https://opencode.ai/docs/mcp-servers/

Config reference:
- Docs: https://opencode.ai/docs/config/

End with two friendly next actions to try in OpenWork."#;

    fs::write(guide_dir.join("SKILL.md"), doc)
        .map_err(|e| format!("Failed to write SKILL.md: {e}"))?;

    Ok(())
}

fn seed_get_started_skill(skill_root: &PathBuf) -> Result<(), String> {
  let skill_dir = skill_root.join("get-started");
    if skill_dir.exists() {
        return Ok(());
    }

    fs::create_dir_all(&skill_dir)
        .map_err(|e| format!("Failed to create {}: {e}", skill_dir.display()))?;

  let doc = r#"---
name: get-started
description: Guide users through the get started setup and Chrome DevTools demo.
---

## When to use
- Always load this skill when the user says \"get started\".

## What to do
- Reply with these four lines, exactly and in order:
  1) hey there welcome this is openwork
  2) we've pre-configured you with a couple tools
  3) Get Started
  4) write \"hey go on google.com\"

## Then
- If the user writes \"go on google.com\" (or \"hey go on google.com\"), use the chrome-devtools MCP to open the site.
- After the navigation completes, reply: \"I'm on <site>\" where <site> is the final URL or page title they asked for.
"#;

    fs::write(skill_dir.join("SKILL.md"), doc)
        .map_err(|e| format!("Failed to write SKILL.md: {e}"))?;

    Ok(())
}

const ENTERPRISE_ARCHIVE_URL: &str =
    "https://github.com/different-ai/openwork-enterprise/archive/refs/heads/main.zip";
const ENTERPRISE_SEED_MARKER: &str = ".openwork-enterprise-creators";

fn seed_enterprise_creator_skills(root: &PathBuf, skill_root: &PathBuf) -> Result<(), String> {
    let marker_path = root.join(".opencode").join(ENTERPRISE_SEED_MARKER);
    if marker_path.exists() {
        return Ok(());
    }

    let mut existing = HashSet::new();
    if let Ok(entries) = fs::read_dir(skill_root) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            if !name.is_empty() {
                existing.insert(name);
            }
        }
    }

    let agent = ureq::AgentBuilder::new().redirects(5).build();
    let response = agent
        .get(ENTERPRISE_ARCHIVE_URL)
        .call()
        .map_err(|e| format!("Failed to download enterprise archive: {e}"))?;

    let mut buffer = Vec::new();
    response
        .into_reader()
        .read_to_end(&mut buffer)
        .map_err(|e| format!("Failed to read enterprise archive: {e}"))?;

    let cursor = Cursor::new(buffer);
    let mut archive = ZipArchive::new(cursor)
        .map_err(|e| format!("Failed to open enterprise archive: {e}"))?;

    for i in 0..archive.len() {
        let mut entry = archive
            .by_index(i)
            .map_err(|e| format!("Failed to read enterprise entry: {e}"))?;
        let name = entry.name().to_string();
        let entry_path = Path::new(&name);
        if entry_path.components().any(|component| match component {
            std::path::Component::ParentDir
            | std::path::Component::RootDir
            | std::path::Component::Prefix(_) => true,
            _ => false,
        }) {
            continue;
        }

        let parts: Vec<String> = entry_path
            .components()
            .map(|component| component.as_os_str().to_string_lossy().to_string())
            .collect();
        if parts.len() < 5 {
            continue;
        }
        if parts[1] != ".opencode" || parts[2] != "skills" {
            continue;
        }

        let skill_name = &parts[3];
        if !skill_name.ends_with("-creator") {
            continue;
        }
        if existing.contains(skill_name) {
            continue;
        }

        let dest_root = skill_root.join(skill_name);
        let mut dest_path = dest_root.clone();
        for part in parts.iter().skip(4) {
            dest_path = dest_path.join(part);
        }

        if name.ends_with('/') {
            fs::create_dir_all(&dest_path)
                .map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
            continue;
        }

        if let Some(parent) = dest_path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
        }

        let mut file_buffer = Vec::new();
        entry
            .read_to_end(&mut file_buffer)
            .map_err(|e| format!("Failed to read enterprise entry: {e}"))?;
        fs::write(&dest_path, file_buffer)
            .map_err(|e| format!("Failed to write {}: {e}", dest_path.display()))?;
    }

    fs::write(&marker_path, "seeded\n")
        .map_err(|e| format!("Failed to write {}: {e}", marker_path.display()))?;

    Ok(())
}

fn seed_commands(commands_dir: &PathBuf, preset: &str) -> Result<(), String> {
  if fs::read_dir(commands_dir)
    .map_err(|e| format!("Failed to read {}: {e}", commands_dir.display()))?
    .next()
    .is_some()
  {
    return Ok(());
  }

  let defaults = vec![
    OpencodeCommand {
      name: "learn-files".to_string(),
      description: Some("Safe, practical file workflows".to_string()),
      template: "Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.".to_string(),
      agent: None,
      model: None,
      subtask: None,
    },
    OpencodeCommand {
      name: "learn-skills".to_string(),
      description: Some("How skills work and how to create your own".to_string()),
      template: "Explain what skills are, how to use them, and how to create a new skill for this workspace.".to_string(),
      agent: None,
      model: None,
      subtask: None,
    },
    OpencodeCommand {
      name: "learn-plugins".to_string(),
      description: Some("What plugins are and how to install them".to_string()),
      template: "Explain what plugins are and how to install them in this workspace.".to_string(),
      agent: None,
      model: None,
      subtask: None,
    },
  ];

  let mut defaults = defaults;
  if preset == "starter" {
    defaults.push(OpencodeCommand {
      name: "Get Started".to_string(),
      description: Some("Get started".to_string()),
      template: "get started".to_string(),
      agent: None,
      model: None,
      subtask: None,
    });
  }

    for command in defaults {
        let Some(name) = sanitize_command_name(&command.name) else {
            continue;
        };

    let file_path = commands_dir.join(format!("{name}.md"));
    if file_path.exists() {
      continue;
    }

    let serialized = serialize_command_frontmatter(&command)?;
    fs::write(&file_path, serialized)
      .map_err(|e| format!("Failed to write {}: {e}", file_path.display()))?;
  }

    Ok(())
}

pub fn ensure_workspace_files(workspace_path: &str, preset: &str) -> Result<(), String> {
    let root = PathBuf::from(workspace_path);

    let skill_root = root.join(".opencode").join("skills");
    fs::create_dir_all(&skill_root)
        .map_err(|e| format!("Failed to create .opencode/skills: {e}"))?;
    seed_workspace_guide(&skill_root)?;
  if preset == "starter" {
    seed_get_started_skill(&skill_root)?;
    if let Err(err) = seed_enterprise_creator_skills(&root, &skill_root) {
      println!("[workspace] Failed to seed creator skills: {err}");
    }
  }

    let commands_dir = root.join(".opencode").join("commands");
    fs::create_dir_all(&commands_dir)
        .map_err(|e| format!("Failed to create .opencode/commands: {e}"))?;
  seed_commands(&commands_dir, preset)?;

    let config_path_jsonc = root.join("opencode.jsonc");
    let config_path_json = root.join("opencode.json");
    let config_path = if config_path_jsonc.exists() {
        config_path_jsonc
    } else if config_path_json.exists() {
        config_path_json
    } else {
        config_path_jsonc
    };

    let config_exists = config_path.exists();
    let mut config_changed = !config_exists;
    let mut config: serde_json::Value = if config_exists {
        let raw = fs::read_to_string(&config_path)
            .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
        json5::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
    } else {
        serde_json::json!({
          "$schema": "https://opencode.ai/config.json"
        })
    };

    if !config.is_object() {
        config = serde_json::json!({
          "$schema": "https://opencode.ai/config.json"
        });
        config_changed = true;
    }

    let required_plugins: Vec<&str> = match preset {
        "starter" => vec!["opencode-scheduler"],
        "automation" => vec!["opencode-scheduler"],
        _ => vec![],
    };

    let should_seed_chrome_mcp = matches!(preset, "starter");

    if !required_plugins.is_empty() {
        let plugins_value = config
            .get("plugin")
            .cloned()
            .unwrap_or_else(|| serde_json::json!([]));

        let existing_plugins: Vec<String> = match plugins_value {
            serde_json::Value::Array(arr) => arr
                .into_iter()
                .filter_map(|v| v.as_str().map(|s| s.to_string()))
                .collect(),
            serde_json::Value::String(s) => vec![s],
            _ => vec![],
        };

        let merged = merge_plugins(existing_plugins.clone(), &required_plugins);
        if merged != existing_plugins {
            config_changed = true;
        }
        if let Some(obj) = config.as_object_mut() {
            obj.insert(
                "plugin".to_string(),
                serde_json::Value::Array(
                    merged.into_iter().map(serde_json::Value::String).collect(),
                ),
            );
        }
    }

    if should_seed_chrome_mcp {
        if let Some(obj) = config.as_object_mut() {
            let mcp_value = obj
                .get("mcp")
                .cloned()
                .unwrap_or_else(|| serde_json::json!({}));

            let mut mcp_obj = match mcp_value {
                serde_json::Value::Object(map) => map,
                _ => serde_json::Map::new(),
            };

            if !mcp_obj.contains_key("chrome-devtools") {
                mcp_obj.insert(
                    "chrome-devtools".to_string(),
                    serde_json::json!({
                      "type": "local",
                      "command": ["npx", "-y", "chrome-devtools-mcp@latest"]
                    }),
                );
                config_changed = true;
            }

            obj.insert("mcp".to_string(), serde_json::Value::Object(mcp_obj));
        }
    }

    if config_changed {
        fs::write(
            &config_path,
            serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?,
        )
        .map_err(|e| format!("Failed to write {}: {e}", config_path.display()))?;
    }

    let openwork_path = root.join(".opencode").join("openwork.json");
    if !openwork_path.exists() {
        let openwork = WorkspaceOpenworkConfig::new(workspace_path, preset, now_ms());

        fs::create_dir_all(openwork_path.parent().unwrap())
            .map_err(|e| format!("Failed to create {}: {e}", openwork_path.display()))?;

        fs::write(
            &openwork_path,
            serde_json::to_string_pretty(&openwork).map_err(|e| e.to_string())?,
        )
        .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?;
    }

    Ok(())
}



================================================
FILE: packages/desktop/src-tauri/src/workspace/mod.rs
================================================
pub mod commands;
pub mod files;
pub mod state;
pub mod watch;



================================================
FILE: packages/desktop/src-tauri/src/workspace/state.rs
================================================
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use tauri::Manager;

use crate::types::{WorkspaceInfo, WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION};

pub fn stable_workspace_id(path: &str) -> String {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    path.hash(&mut hasher);
    format!("ws-{:x}", hasher.finish())
}

pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
    let data_dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
    let file_path = data_dir.join("openwork-workspaces.json");
    Ok((data_dir, file_path))
}

pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, String> {
    let (_, path) = openwork_state_paths(app)?;
    if !path.exists() {
        return Ok(WorkspaceState::default());
    }

    let raw =
        fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
    let mut state: WorkspaceState = serde_json::from_str(&raw)
        .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;

    if state.version < WORKSPACE_STATE_VERSION {
        state.version = WORKSPACE_STATE_VERSION;
    }

    Ok(state)
}

pub fn save_workspace_state(app: &tauri::AppHandle, state: &WorkspaceState) -> Result<(), String> {
    let (dir, path) = openwork_state_paths(app)?;
    fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
    fs::write(
        &path,
        serde_json::to_string_pretty(state).map_err(|e| e.to_string())?,
    )
    .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
    Ok(())
}

pub fn ensure_starter_workspace(app: &tauri::AppHandle) -> Result<WorkspaceInfo, String> {
    let data_dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
    let starter_dir = data_dir.join("workspaces").join("starter");
    fs::create_dir_all(&starter_dir)
        .map_err(|e| format!("Failed to create starter workspace: {e}"))?;

    Ok(WorkspaceInfo {
        id: stable_workspace_id(starter_dir.to_string_lossy().as_ref()),
        name: "Starter".to_string(),
        path: starter_dir.to_string_lossy().to_string(),
        preset: "starter".to_string(),
        workspace_type: WorkspaceType::Local,
        remote_type: None,
        base_url: None,
        directory: None,
        display_name: None,
        openwork_host_url: None,
        openwork_workspace_id: None,
        openwork_workspace_name: None,
    })
}

pub fn stable_workspace_id_for_remote(base_url: &str, directory: Option<&str>) -> String {
    let mut key = format!("remote::{base_url}");
    if let Some(dir) = directory {
        if !dir.trim().is_empty() {
            key.push_str("::");
            key.push_str(dir.trim());
        }
    }
    stable_workspace_id(&key)
}

pub fn stable_workspace_id_for_openwork(host_url: &str, workspace_id: Option<&str>) -> String {
    let mut key = format!("openwork::{host_url}");
    if let Some(id) = workspace_id {
        if !id.trim().is_empty() {
            key.push_str("::");
            key.push_str(id.trim());
        }
    }
    stable_workspace_id(&key)
}



================================================
FILE: packages/desktop/src-tauri/src/workspace/watch.rs
================================================
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::json;
use tauri::{AppHandle, Emitter, State};

use crate::types::{WorkspaceInfo, WorkspaceType};

const RELOAD_EVENT: &str = "openwork://reload-required";

#[derive(Default)]
pub struct WorkspaceWatchState {
    watcher: Mutex<Option<RecommendedWatcher>>,
    last_emit: Arc<Mutex<Option<Instant>>>,
    root: Mutex<Option<PathBuf>>,
}

fn normalize_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn reason_for_path(path: &Path) -> Option<&'static str> {
    let normalized = normalize_path(path);
    let lower = normalized.to_lowercase();
    if lower.contains("/.opencode/skills/") || lower.ends_with("/.opencode/skills") {
        return Some("skills");
    }
    if lower.contains("/.opencode/") || lower.ends_with("/.opencode") {
        return Some("config");
    }
    if lower.ends_with("/opencode.json") || lower.ends_with("/opencode.jsonc") {
        return Some("config");
    }
    None
}

fn should_emit(last_emit: &Arc<Mutex<Option<Instant>>>) -> bool {
    let mut guard = last_emit
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let now = Instant::now();
    if let Some(previous) = *guard {
        if now.duration_since(previous) < Duration::from_millis(750) {
            return false;
        }
    }
    *guard = Some(now);
    true
}

pub fn update_workspace_watch(
    app: &AppHandle,
    state: State<WorkspaceWatchState>,
    workspace: Option<&WorkspaceInfo>,
) -> Result<(), String> {
    let mut watcher_guard = state
        .watcher
        .lock()
        .map_err(|_| "Failed to lock workspace watcher".to_string())?;
    *watcher_guard = None;
    *state
        .root
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;

    let Some(active) = workspace else {
        return Ok(());
    };
    if active.workspace_type != WorkspaceType::Local {
        return Ok(());
    }

    let root = PathBuf::from(active.path.trim());
    if root.as_os_str().is_empty() {
        return Ok(());
    }

    let app_handle = app.clone();
    let last_emit = state.last_emit.clone();
    let mut watcher = notify::recommended_watcher(move |result| {
        let event: Event = match result {
            Ok(event) => event,
            Err(_) => return,
        };

        match event.kind {
            EventKind::Create(_) | EventKind::Remove(_) => {}
            EventKind::Modify(mod_kind) => match mod_kind {
                notify::event::ModifyKind::Data(_)
                | notify::event::ModifyKind::Name(_)
                | notify::event::ModifyKind::Any => {}
                _ => return,
            },
            _ => return,
        }

        for path in event.paths {
            if path.is_dir() {
                continue;
            }

            let Some(reason) = reason_for_path(&path) else {
                continue;
            };

            let lower = path.to_string_lossy().to_lowercase();
            if lower.ends_with(".ds_store")
                || lower.ends_with("desktop.ini")
                || lower.ends_with(".localized")
            {
                continue;
            }

            if !should_emit(&last_emit) {
                break;
            }
            let payload = json!({
                "reason": reason,
                "path": path.to_string_lossy().to_string(),
            });
            let _ = app_handle.emit(RELOAD_EVENT, payload);
            break;
        }
    })
    .map_err(|e| format!("Failed to create workspace watcher: {e}"))?;

    watcher
        .watch(&root, RecursiveMode::NonRecursive)
        .map_err(|e| format!("Failed to watch workspace root: {e}"))?;

    let opencode_dir = root.join(".opencode");
    if opencode_dir.exists() {
        watcher
            .watch(&opencode_dir, RecursiveMode::Recursive)
            .map_err(|e| format!("Failed to watch .opencode: {e}"))?;
    }

    *state
        .root
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(root);
    *watcher_guard = Some(watcher);
    Ok(())
}



================================================
FILE: packages/owpenbot/README.md
================================================
# Owpenbot

Simple WhatsApp bridge for a running OpenCode server. Telegram support exists but is not yet E2E tested.

## Install + Run (WhatsApp)

One-command install (recommended):

```bash
curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash
```

Or install from npm:

```bash
npm install -g owpenwork
```

Quick run without install:

```bash
npx owpenwork
```

Then follow the guided setup (choose what to configure, link WhatsApp, start).

1) One-command setup (installs deps, builds, creates `.env` if missing):

```bash
pnpm -C packages/owpenbot setup
```

2) (Optional) Fill in `packages/owpenbot/.env` (see `.env.example`).

Required:
- `OPENCODE_URL`
- `OPENCODE_DIRECTORY`
- `WHATSAPP_AUTH_DIR`

Recommended:
- `OPENCODE_SERVER_USERNAME`
- `OPENCODE_SERVER_PASSWORD`

3) Run owpenwork and follow the guided setup:

```bash
owpenwork
```

Owpenwork keeps the WhatsApp session alive once connected.

6) Pair a user with the bot (only if DM policy is pairing):

- Run `owpenwork pairing list` to view pending codes.
- Approve a code: `owpenwork pairing approve <code>`.
- The user can then message again to receive OpenCode replies.

## Usage Flows

### One-person flow (personal testing)

Use your own WhatsApp account as the bot and test from a second number you control.

1) Run `owpenwork` and choose “personal number.”
2) Scan the QR when prompted.
3) Message yourself or from a second number; your number is already allowlisted.

Note: WhatsApp’s “message yourself” thread is not reliable for bot testing.

### Two-person flow (dedicated bot)

Use a separate WhatsApp number as the bot account so it stays independent from your personal chat history.

1) Create a new WhatsApp account for the dedicated number.
2) Run `owpenwork` and choose “dedicated number.”
3) Scan the QR when prompted.
4) If DM policy is pairing, approve codes with `owpenwork pairing approve <code>`.

## Telegram (Untested)

Telegram support is wired but not E2E tested yet. To try it:
- Run `owpenwork login telegram --token <token>`.
- Or set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ENABLED=true`.

## Commands

```bash
owpenwork
owpenwork --non-interactive
owpenwork login whatsapp
owpenwork login telegram --token <token>
owpenwork pairing list
owpenwork pairing approve <code>
owpenwork status
owpenwork doctor --reset
```

## Defaults

- SQLite at `~/.owpenbot/owpenbot.db` unless overridden.
- Config stored at `~/.owpenbot/owpenbot.json` (created by `owpenwork` or `owpenwork setup`).
- DM policy defaults to `pairing` unless changed in setup.
- Group chats are disabled unless `GROUPS_ENABLED=true`.

## Tests

```bash
pnpm -C packages/owpenbot test:unit
pnpm -C packages/owpenbot test:smoke
pnpm -C packages/owpenbot test:cli
pnpm -C packages/owpenbot test:npx
```



================================================
FILE: packages/owpenbot/install.sh
================================================
#!/usr/bin/env bash
set -euo pipefail

OWPENBOT_REF="${OWPENBOT_REF:-dev}"
OWPENBOT_REPO="${OWPENBOT_REPO:-https://github.com/different-ai/openwork.git}"
OWPENBOT_INSTALL_DIR="${OWPENBOT_INSTALL_DIR:-$HOME/.owpenbot/openwork}"
OWPENBOT_BIN_DIR="${OWPENBOT_BIN_DIR:-$HOME/.local/bin}"
OWPENBOT_INSTALL_METHOD="${OWPENBOT_INSTALL_METHOD:-npm}"

usage() {
  cat <<'EOF'
Owpenbot installer (WhatsApp-first)

Environment variables:
  OWPENBOT_INSTALL_DIR  Install directory (default: ~/.owpenbot/openwork)
  OWPENBOT_REPO         Git repo (default: https://github.com/different-ai/openwork.git)
  OWPENBOT_REF          Git ref/branch (default: dev)
  OWPENBOT_BIN_DIR      Bin directory for owpenbot shim (default: ~/.local/bin)
  OWPENBOT_INSTALL_METHOD  Install method: npm|git (default: npm)

Example:
  OWPENBOT_INSTALL_DIR=~/owpenbot curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash
EOF
}

if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
  usage
  exit 0
fi

require_bin() {
  if ! command -v "$1" >/dev/null 2>&1; then
    echo "Missing $1. Please install it and retry." >&2
    exit 1
  fi
}

require_bin node

if [[ "$OWPENBOT_INSTALL_METHOD" == "npm" ]]; then
  echo "Installing owpenwork via npm..."
  npm install -g owpenwork
else
  require_bin git
  if ! command -v pnpm >/dev/null 2>&1; then
    if command -v corepack >/dev/null 2>&1; then
      corepack enable >/dev/null 2>&1 || true
      corepack prepare pnpm@10.27.0 --activate
    else
      echo "pnpm is required. Install pnpm or enable corepack, then retry." >&2
      exit 1
    fi
  fi

  if [[ -d "$OWPENBOT_INSTALL_DIR/.git" ]]; then
    echo "Updating owpenbot source in $OWPENBOT_INSTALL_DIR"
    git -C "$OWPENBOT_INSTALL_DIR" fetch origin --prune
    if git -C "$OWPENBOT_INSTALL_DIR" show-ref --verify --quiet "refs/remotes/origin/$OWPENBOT_REF"; then
      git -C "$OWPENBOT_INSTALL_DIR" checkout -B "$OWPENBOT_REF" "origin/$OWPENBOT_REF"
      git -C "$OWPENBOT_INSTALL_DIR" pull --ff-only origin "$OWPENBOT_REF"
    else
      git -C "$OWPENBOT_INSTALL_DIR" checkout -f
      git -C "$OWPENBOT_INSTALL_DIR" pull --ff-only
    fi
  else
    echo "Cloning owpenbot source to $OWPENBOT_INSTALL_DIR"
    mkdir -p "$OWPENBOT_INSTALL_DIR"
    git clone --depth 1 "$OWPENBOT_REPO" "$OWPENBOT_INSTALL_DIR"
    if git -C "$OWPENBOT_INSTALL_DIR" show-ref --verify --quiet "refs/remotes/origin/$OWPENBOT_REF"; then
      git -C "$OWPENBOT_INSTALL_DIR" checkout -B "$OWPENBOT_REF" "origin/$OWPENBOT_REF"
    fi
  fi

  if [[ ! -d "$OWPENBOT_INSTALL_DIR/packages/owpenbot" ]]; then
    echo "owpenbot package not found on ref '$OWPENBOT_REF'. Trying dev/main..." >&2
    git -C "$OWPENBOT_INSTALL_DIR" fetch origin --prune
    if git -C "$OWPENBOT_INSTALL_DIR" show-ref --verify --quiet refs/remotes/origin/dev; then
      git -C "$OWPENBOT_INSTALL_DIR" checkout -B dev origin/dev
    elif git -C "$OWPENBOT_INSTALL_DIR" show-ref --verify --quiet refs/remotes/origin/main; then
      git -C "$OWPENBOT_INSTALL_DIR" checkout -B main origin/main
    fi
  fi

  if [[ ! -d "$OWPENBOT_INSTALL_DIR/packages/owpenbot" ]]; then
    echo "owpenbot package not found after checkout. Aborting." >&2
    exit 1
  fi

  echo "Installing dependencies..."
  pnpm -C "$OWPENBOT_INSTALL_DIR" install

  echo "Building owpenbot..."
  pnpm -C "$OWPENBOT_INSTALL_DIR/packages/owpenbot" build

  ENV_PATH="$OWPENBOT_INSTALL_DIR/packages/owpenbot/.env"
  ENV_EXAMPLE="$OWPENBOT_INSTALL_DIR/packages/owpenbot/.env.example"
  if [[ ! -f "$ENV_PATH" ]]; then
    if [[ -f "$ENV_EXAMPLE" ]]; then
      cp "$ENV_EXAMPLE" "$ENV_PATH"
      echo "Created $ENV_PATH"
    else
      cat <<EOF > "$ENV_PATH"
OPENCODE_URL=http://127.0.0.1:4096
OPENCODE_DIRECTORY=
WHATSAPP_AUTH_DIR=~/.owpenbot/whatsapp
EOF
      echo "Created $ENV_PATH (minimal)"
    fi
  fi

  mkdir -p "$OWPENBOT_BIN_DIR"
  cat <<EOF > "$OWPENBOT_BIN_DIR/owpenbot"
#!/usr/bin/env bash
set -euo pipefail
node "$OWPENBOT_INSTALL_DIR/packages/owpenbot/dist/cli.js" "$@"
EOF
  chmod 755 "$OWPENBOT_BIN_DIR/owpenbot"
fi

if ! echo ":$PATH:" | grep -q ":$OWPENBOT_BIN_DIR:"; then
  shell_name="$(basename "${SHELL:-}" 2>/dev/null || true)"
  case "$shell_name" in
    fish)
      echo "Add to PATH (fish): set -Ux PATH $OWPENBOT_BIN_DIR \$PATH"
      ;;
    zsh)
      echo "Add to PATH (zsh):  echo 'export PATH=\"$OWPENBOT_BIN_DIR:\$PATH\"' >> ~/.zshrc"
      ;;
    bash)
      echo "Add to PATH (bash): echo 'export PATH=\"$OWPENBOT_BIN_DIR:\$PATH\"' >> ~/.bashrc"
      ;;
    *)
      echo "Add to PATH: export PATH=\"$OWPENBOT_BIN_DIR:\$PATH\""
      ;;
  esac
fi

cat <<EOF

Owpenbot installed.

Next steps:
1) Run owpenwork: owpenwork
2) Follow the guided setup + QR login

Owpenbot will print a QR code during login and keep the session alive.
EOF



================================================
FILE: packages/owpenbot/package.json
================================================
{
  "name": "owpenwork",
  "version": "0.1.16",
  "description": "WhatsApp bridge for a running OpenCode server",
  "private": false,
  "type": "module",
  "bin": {
    "owpenwork": "dist/cli.js",
    "owpenbot": "dist/cli.js"
  },
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/different-ai/openwork.git",
    "directory": "packages/owpenbot"
  },
  "keywords": [
    "opencode",
    "whatsapp",
    "bridge",
    "bot"
  ],
  "files": [
    "dist",
    "README.md",
    ".env.example",
    "install.sh"
  ],
  "scripts": {
    "dev": "bun src/cli.ts",
    "build": "tsc -p tsconfig.json",
    "build:bin": "bun ./script/build.ts --outdir dist/bin",
    "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-windows-x64",
    "build:binary": "bun ./script/build.ts --outdir dist/bin",
    "start": "bun dist/cli.js start",
    "whatsapp:login": "bun dist/cli.js whatsapp login",
    "pairing-code": "bun dist/cli.js pairing-code",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "setup": "bun scripts/setup.mjs",
    "test:unit": "pnpm build && node --test test/*.test.js",
    "test:smoke": "bun scripts/smoke.mjs",
    "test:cli": "pnpm build && bun scripts/test-cli.mjs",
    "test:npx": "bun scripts/test-npx.mjs"
  },
  "dependencies": {
    "@opencode-ai/sdk": "^1.1.31",
    "@whiskeysockets/baileys": "7.0.0-rc.9",
    "commander": "^12.1.0",
    "dotenv": "^16.4.7",
    "grammy": "^1.39.3",
    "pino": "^9.6.0",
    "qrcode-terminal": "^0.12.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "@types/qrcode-terminal": "^0.12.0",
    "bun-types": "^1.3.6",
    "typescript": "^5.6.3"
  }
}



================================================
FILE: packages/owpenbot/tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "types": ["bun-types", "node"]
  },
  "include": ["src"]
}



================================================
FILE: packages/owpenbot/.env.example
================================================
OPENCODE_URL=http://127.0.0.1:4096
OPENCODE_DIRECTORY=
OPENCODE_SERVER_USERNAME=
OPENCODE_SERVER_PASSWORD=

TELEGRAM_BOT_TOKEN=
TELEGRAM_ENABLED=true

OWPENBOT_DATA_DIR=~/.owpenbot
OWPENBOT_DB_PATH=~/.owpenbot/owpenbot.db
OWPENBOT_CONFIG_PATH=~/.owpenbot/owpenbot.json
OWPENBOT_HEALTH_PORT=3005

WHATSAPP_ACCOUNT_ID=default
WHATSAPP_AUTH_DIR=
WHATSAPP_ENABLED=true
WHATSAPP_DM_POLICY=
WHATSAPP_SELF_CHAT=false

ALLOW_FROM=
ALLOW_FROM_TELEGRAM=
ALLOW_FROM_WHATSAPP=
TOOL_UPDATES_ENABLED=false
GROUPS_ENABLED=false
TOOL_OUTPUT_LIMIT=1200
PERMISSION_MODE=allow
LOG_LEVEL=info



================================================
FILE: packages/owpenbot/script/build.ts
================================================
import { spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join, resolve } from "node:path";

const bunRuntime = (globalThis as typeof globalThis & {
  Bun?: {
    build?: (...args: any[]) => Promise<any>;
    argv?: string[];
  };
}).Bun;

if (!bunRuntime?.build || !bunRuntime.argv) {
  console.error("This script must be run with Bun.");
  process.exit(1);
}

const bun = bunRuntime as { build: (...args: any[]) => Promise<any>; argv: string[] };

type BuildOptions = {
  targets: string[];
  outdir: string;
  filename: string;
};

function readArgs(argv: string[]): BuildOptions {
  const options: BuildOptions = {
    targets: [],
    outdir: resolve("dist", "bin"),
    filename: "owpenbot",
  };

  for (let index = 0; index < argv.length; index += 1) {
    const value = argv[index];
    if (!value) continue;

    if (value === "--target") {
      const next = argv[index + 1];
      if (next) {
        options.targets.push(next);
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--target=")) {
      const next = value.slice("--target=".length).trim();
      if (next) options.targets.push(next);
      continue;
    }

    if (value === "--outdir") {
      const next = argv[index + 1];
      if (next) {
        options.outdir = resolve(next);
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--outdir=")) {
      const next = value.slice("--outdir=".length).trim();
      if (next) options.outdir = resolve(next);
      continue;
    }

    if (value === "--filename") {
      const next = argv[index + 1];
      if (next) {
        options.filename = next;
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--filename=")) {
      const next = value.slice("--filename=".length).trim();
      if (next) options.filename = next;
    }
  }

  return options;
}

function outputName(filename: string, target?: string) {
  const needsExe = target ? target.includes("windows") : process.platform === "win32";
  const suffix = target ? `-${target}` : "";
  const ext = needsExe ? ".exe" : "";
  return `${filename}${suffix}${ext}`;
}

async function buildOnce(entrypoint: string, outdir: string, filename: string, target?: string) {
  mkdirSync(outdir, { recursive: true });
  const outfile = join(outdir, outputName(filename, target));

  const args = ["build", entrypoint, "--compile", "--outfile", outfile];
  if (target) {
    args.push("--target", target);
  }

  const result = spawnSync("bun", args, { stdio: "inherit" });
  if (result.status !== 0) {
    process.exit(result.status ?? 1);
  }
}

const options = readArgs(bun.argv.slice(2));
const entrypoint = resolve("src", "cli.ts");
const targets = options.targets.length ? options.targets : [undefined];

for (const target of targets) {
  await buildOnce(entrypoint, options.outdir, options.filename, target);
}



================================================
FILE: packages/owpenbot/scripts/setup.mjs
================================================
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(packageDir, "..", "..");

const envPath = path.join(packageDir, ".env");
const envExamplePath = path.join(packageDir, ".env.example");

const install = spawnSync("pnpm", ["install"], { cwd: rootDir, stdio: "inherit" });
if (install.status !== 0) {
  process.exit(install.status ?? 1);
}

const build = spawnSync("pnpm", ["-C", packageDir, "build"], { cwd: rootDir, stdio: "inherit" });
if (build.status !== 0) {
  process.exit(build.status ?? 1);
}

if (!existsSync(envPath) && existsSync(envExamplePath)) {
  copyFileSync(envExamplePath, envPath);
  console.log("Created .env from .env.example");
}



================================================
FILE: packages/owpenbot/scripts/smoke.mjs
================================================
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";

import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";

const args = new Set(process.argv.slice(2));
const requireReply = args.has("--reply");

const baseUrl = process.env.OPENCODE_URL ?? "http://127.0.0.1:4096";
const directory = process.env.OPENCODE_DIRECTORY ?? process.cwd();

const headers = {};
if (process.env.OPENCODE_SERVER_USERNAME && process.env.OPENCODE_SERVER_PASSWORD) {
  const token = Buffer.from(
    `${process.env.OPENCODE_SERVER_USERNAME}:${process.env.OPENCODE_SERVER_PASSWORD}`,
  ).toString("base64");
  headers.Authorization = `Basic ${token}`;
}

const client = createOpencodeClient({
  baseUrl,
  directory,
  headers: Object.keys(headers).length ? headers : undefined,
  responseStyle: "data",
  throwOnError: true,
});

const health = await client.global.health();
assert.equal(health.healthy, true);

const session = await client.session.create({ title: "owpenbot smoke" });
assert.ok(session?.id);

await client.session.prompt({
  sessionID: session.id,
  noReply: !requireReply,
  parts: [{ type: "text", text: "ping" }],
});

const messages = await client.session.messages({ sessionID: session.id, limit: 20 });
assert.ok(Array.isArray(messages));

console.log(
  JSON.stringify({
    ok: true,
    baseUrl,
    directory,
    sessionID: session.id,
    messageCount: messages.length,
  }),
);



================================================
FILE: packages/owpenbot/scripts/test-cli.mjs
================================================
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const cliPath = path.resolve("dist", "cli.js");

function run(cmd, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      stdio: ["pipe", "pipe", "pipe"],
      ...options,
    });
    let stdout = "";
    let stderr = "";
    let timedOut = false;

    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
    });

    const timeout = setTimeout(() => {
      timedOut = true;
      child.kill("SIGKILL");
      if (options.expectTimeout) {
        resolve({ code: null, stdout, stderr, timedOut: true });
        return;
      }
      reject(new Error(`Timeout running ${cmd} ${args.join(" ")}`));
    }, options.timeoutMs ?? 5000);

    child.on("close", (code) => {
      if (timedOut) return;
      clearTimeout(timeout);
      resolve({ code, stdout, stderr, timedOut: false });
    });

    if (options.input) {
      child.stdin.write(options.input);
      child.stdin.end();
    }
  });
}

async function runHelp() {
  const result = await run("node", [cliPath, "--help"], { timeoutMs: 3000 });
  if (result.code !== 0) {
    throw new Error(`Help failed: ${result.stderr}`);
  }
  if (!result.stdout.includes("OpenCode WhatsApp")) {
    throw new Error("Help output missing expected header");
  }
}

async function runSetupNonInteractive() {
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "owpenbot-"));
  const env = {
    ...process.env,
    OWPENBOT_DATA_DIR: tempDir,
    OWPENBOT_DB_PATH: path.join(tempDir, "owpenbot.db"),
    OWPENBOT_CONFIG_PATH: path.join(tempDir, "owpenbot.json"),
    OPENCODE_DIRECTORY: tempDir,
  };
  const result = await run("node", [cliPath, "--non-interactive"], {
    env,
    timeoutMs: 5000,
  });
  if (result.code !== 0) {
    throw new Error(`Setup failed: ${result.stderr}`);
  }
  const cfg = await fs.readFile(path.join(tempDir, "owpenbot.json"), "utf-8");
  if (!cfg.includes("whatsapp")) {
    throw new Error("Config missing whatsapp section");
  }
}

async function runSetupInteractive() {
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "owpenbot-"));
  const env = {
    ...process.env,
    OWPENBOT_DATA_DIR: tempDir,
    OWPENBOT_DB_PATH: path.join(tempDir, "owpenbot.db"),
    OWPENBOT_CONFIG_PATH: path.join(tempDir, "owpenbot.json"),
    OPENCODE_DIRECTORY: tempDir,
    OWPENWORK_TEST_SELECTIONS: "config",
    OWPENWORK_TEST_SETUP: "personal",
    OWPENWORK_FORCE_TUI: "1",
  };
  const result = await run("node", ["--no-warnings", cliPath], {
    env,
    timeoutMs: 5000,
  });
  const output = `${result.stdout}${result.stderr}`;
  if (!output.includes("Owpenwork Setup")) {
    throw new Error("Interactive setup prompt not detected");
  }
  const cfg = await fs.readFile(path.join(tempDir, "owpenbot.json"), "utf-8");
  if (!cfg.includes("+15551234567")) {
    throw new Error("Interactive config missing allowlist number");
  }
}

await runHelp();
await runSetupNonInteractive();
await runSetupInteractive();
console.log("CLI smoke tests passed");



================================================
FILE: packages/owpenbot/scripts/test-npx.mjs
================================================
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

function run(cmd, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      stdio: ["ignore", "pipe", "pipe"],
      ...options,
    });
    let stdout = "";
    let stderr = "";

    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
    });

    const timeout = setTimeout(() => {
      child.kill("SIGKILL");
      reject(new Error(`Timeout running ${cmd} ${args.join(" ")}`));
    }, options.timeoutMs ?? 60000);

    child.on("close", (code) => {
      clearTimeout(timeout);
      resolve({ code, stdout, stderr });
    });
  });
}

const tempCache = await fs.mkdtemp(path.join(os.tmpdir(), "owpenbot-npx-"));
const env = {
  ...process.env,
  npm_config_yes: "true",
  NPM_CONFIG_CACHE: tempCache,
};

const result = await run("npx", ["--yes", "owpenwork", "--help"], {
  env,
  timeoutMs: 60000,
});

if (result.code !== 0) {
  throw new Error(result.stderr || "npx owpenwork failed");
}

if (!result.stdout.includes("OpenCode WhatsApp")) {
  throw new Error("npx output missing expected header");
}

console.log("npx owpenwork ok");



================================================
FILE: packages/owpenbot/src/bridge.ts
================================================
import { setTimeout as delay } from "node:timers/promises";

import type { Logger } from "pino";

import type { Config, ChannelName } from "./config.js";
import { normalizeWhatsAppId } from "./config.js";
import { BridgeStore } from "./db.js";
import { normalizeEvent } from "./events.js";
import { startHealthServer, type HealthSnapshot } from "./health.js";
import { buildPermissionRules, createClient } from "./opencode.js";
import { chunkText, formatInputSummary, truncateText } from "./text.js";
import { createTelegramAdapter } from "./telegram.js";
import { createWhatsAppAdapter } from "./whatsapp.js";

type Adapter = {
  name: ChannelName;
  maxTextLength: number;
  start(): Promise<void>;
  stop(): Promise<void>;
  sendText(peerId: string, text: string): Promise<void>;
  sendTyping?: (peerId: string) => Promise<void>;
};

type OutboundKind = "reply" | "system" | "tool";

export type BridgeReporter = {
  onStatus?: (message: string) => void;
  onInbound?: (message: { channel: ChannelName; peerId: string; text: string; fromMe?: boolean }) => void;
  onOutbound?: (message: { channel: ChannelName; peerId: string; text: string; kind: OutboundKind }) => void;
};

type InboundMessage = {
  channel: ChannelName;
  peerId: string;
  text: string;
  raw: unknown;
  fromMe?: boolean;
};

type ModelRef = {
  providerID: string;
  modelID: string;
};

type RunState = {
  sessionID: string;
  channel: ChannelName;
  peerId: string;
  toolUpdatesEnabled: boolean;
  seenToolStates: Map<string, string>;
  thinkingLabel?: string;
  thinkingActive?: boolean;
};

const TOOL_LABELS: Record<string, string> = {
  bash: "bash",
  read: "read",
  write: "write",
  edit: "edit",
  patch: "patch",
  multiedit: "edit",
  grep: "grep",
  glob: "glob",
  task: "agent",
  webfetch: "webfetch",
};

const CHANNEL_LABELS: Record<ChannelName, string> = {
  whatsapp: "WhatsApp",
  telegram: "Telegram",
};

const TYPING_INTERVAL_MS = 6000;

export async function startBridge(config: Config, logger: Logger, reporter?: BridgeReporter) {
  const reportStatus = reporter?.onStatus;
  const client = createClient(config);
  const store = new BridgeStore(config.dbPath);
  store.seedAllowlist("telegram", config.allowlist.telegram);
  store.seedAllowlist(
    "whatsapp",
    [...config.whatsappAllowFrom].filter((entry) => entry !== "*"),
  );
  store.prunePairingRequests();

  logger.debug(
    {
      configPath: config.configPath,
      opencodeUrl: config.opencodeUrl,
      opencodeDirectory: config.opencodeDirectory,
      telegramEnabled: config.telegramEnabled,
      telegramTokenPresent: Boolean(config.telegramToken),
      whatsappEnabled: config.whatsappEnabled,
      groupsEnabled: config.groupsEnabled,
      permissionMode: config.permissionMode,
      toolUpdatesEnabled: config.toolUpdatesEnabled,
    },
    "bridge config",
  );

  const adapters = new Map<ChannelName, Adapter>();
  if (config.telegramEnabled && config.telegramToken) {
    logger.debug("telegram adapter enabled");
    adapters.set("telegram", createTelegramAdapter(config, logger, handleInbound));
  } else {
    logger.info("telegram adapter disabled");
    reportStatus?.("Telegram adapter disabled.");
  }

  if (config.whatsappEnabled) {
    logger.debug("whatsapp adapter enabled");
    adapters.set(
      "whatsapp",
      createWhatsAppAdapter(config, logger, handleInbound, { printQr: true, onStatus: reportStatus }),
    );
  } else {
    logger.info("whatsapp adapter disabled");
    reportStatus?.("WhatsApp adapter disabled.");
  }

  const sessionQueue = new Map<string, Promise<void>>();
  const activeRuns = new Map<string, RunState>();
  const sessionModels = new Map<string, ModelRef>();
  const typingLoops = new Map<string, NodeJS.Timeout>();

  const formatPeer = (channel: ChannelName, peerId: string) =>
    channel === "whatsapp" ? normalizeWhatsAppId(peerId) : peerId;

  const formatModelLabel = (model?: ModelRef) =>
    model ? `${model.providerID}/${model.modelID}` : null;

  const extractModelRef = (info: unknown): ModelRef | null => {
    if (!info || typeof info !== "object") return null;
    const record = info as { role?: unknown; model?: unknown };
    if (record.role !== "user") return null;
    if (!record.model || typeof record.model !== "object") return null;
    const model = record.model as { providerID?: unknown; modelID?: unknown };
    if (typeof model.providerID !== "string" || typeof model.modelID !== "string") return null;
    return { providerID: model.providerID, modelID: model.modelID };
  };

  const reportThinking = (run: RunState) => {
    if (!reportStatus) return;
    const modelLabel = formatModelLabel(sessionModels.get(run.sessionID));
    const nextLabel = modelLabel ? `Thinking (${modelLabel})` : "Thinking...";
    if (run.thinkingLabel === nextLabel && run.thinkingActive) return;
    run.thinkingLabel = nextLabel;
    run.thinkingActive = true;
    reportStatus(`[${CHANNEL_LABELS[run.channel]}] ${formatPeer(run.channel, run.peerId)} ${nextLabel}`);
  };

  const reportDone = (run: RunState) => {
    if (!reportStatus || !run.thinkingActive) return;
    const modelLabel = formatModelLabel(sessionModels.get(run.sessionID));
    const suffix = modelLabel ? ` (${modelLabel})` : "";
    reportStatus(`[${CHANNEL_LABELS[run.channel]}] ${formatPeer(run.channel, run.peerId)} Done${suffix}`);
    run.thinkingActive = false;
  };

  const startTyping = (run: RunState) => {
    const adapter = adapters.get(run.channel);
    if (!adapter?.sendTyping) return;
    if (typingLoops.has(run.sessionID)) return;
    const sendTyping = async () => {
      try {
        await adapter.sendTyping?.(run.peerId);
      } catch (error) {
        logger.warn({ error, channel: run.channel }, "typing update failed");
      }
    };
    void sendTyping();
    const timer = setInterval(sendTyping, TYPING_INTERVAL_MS);
    typingLoops.set(run.sessionID, timer);
  };

  const stopTyping = (sessionID: string) => {
    const timer = typingLoops.get(sessionID);
    if (!timer) return;
    clearInterval(timer);
    typingLoops.delete(sessionID);
  };

  let opencodeHealthy = false;
  let opencodeVersion: string | undefined;

  async function refreshHealth() {
    try {
      const health = await client.global.health();
      opencodeHealthy = Boolean((health as { healthy?: boolean }).healthy);
      opencodeVersion = (health as { version?: string }).version;
    } catch (error) {
      logger.warn({ error }, "failed to reach opencode health");
      opencodeHealthy = false;
    }
  }

  await refreshHealth();
  const healthTimer = setInterval(refreshHealth, 30_000);

  let stopHealthServer: (() => void) | null = null;
  if (config.healthPort) {
    stopHealthServer = startHealthServer(
      config.healthPort,
      (): HealthSnapshot => ({
        ok: opencodeHealthy,
        opencode: {
          url: config.opencodeUrl,
          healthy: opencodeHealthy,
          version: opencodeVersion,
        },
        channels: {
          telegram: adapters.has("telegram"),
          whatsapp: adapters.has("whatsapp"),
        },
      }),
      logger,
    );
  }

  const eventAbort = new AbortController();
  void (async () => {
    const subscription = await client.event.subscribe(undefined, { signal: eventAbort.signal });
    for await (const raw of subscription.stream as AsyncIterable<unknown>) {
      const event = normalizeEvent(raw as any);
      if (!event) continue;

      if (event.type === "message.updated") {
        if (event.properties && typeof event.properties === "object") {
          const record = event.properties as Record<string, unknown>;
          const info = record.info as Record<string, unknown> | undefined;
          const sessionID = typeof info?.sessionID === "string" ? (info.sessionID as string) : null;
          const model = extractModelRef(info);
          if (sessionID && model) {
            sessionModels.set(sessionID, model);
            const run = activeRuns.get(sessionID);
            if (run) reportThinking(run);
          }
        }
      }

      if (event.type === "session.status") {
        if (event.properties && typeof event.properties === "object") {
          const record = event.properties as Record<string, unknown>;
          const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
          const status = record.status as { type?: unknown } | undefined;
          if (sessionID && (status?.type === "busy" || status?.type === "retry")) {
            const run = activeRuns.get(sessionID);
            if (run) {
              reportThinking(run);
              startTyping(run);
            }
          }
        }
      }

      if (event.type === "session.idle") {
        if (event.properties && typeof event.properties === "object") {
          const record = event.properties as Record<string, unknown>;
          const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
          if (sessionID) {
            stopTyping(sessionID);
            const run = activeRuns.get(sessionID);
            if (run) reportDone(run);
          }
        }
      }

      if (event.type === "message.part.updated") {
        const part = (event.properties as { part?: any })?.part;
        if (!part?.sessionID) continue;
        const run = activeRuns.get(part.sessionID);
        if (!run || !run.toolUpdatesEnabled) continue;
        if (part.type !== "tool") continue;

        const callId = part.callID as string | undefined;
        if (!callId) continue;
        const state = part.state as { status?: string; input?: Record<string, unknown>; output?: string; title?: string };
        const status = state?.status ?? "unknown";
        if (run.seenToolStates.get(callId) === status) continue;
        run.seenToolStates.set(callId, status);

        const label = TOOL_LABELS[part.tool] ?? part.tool;
        const title = state.title || truncateText(formatInputSummary(state.input ?? {}), 120) || "running";
        let message = `[tool] ${label} ${status}: ${title}`;

        if (status === "completed" && state.output) {
          const output = truncateText(state.output.trim(), config.toolOutputLimit);
          if (output) message += `\n${output}`;
        }

        await sendText(run.channel, run.peerId, message, { kind: "tool" });
      }

      if (event.type === "permission.asked") {
        const permission = event.properties as { id?: string; sessionID?: string };
        if (!permission?.id || !permission.sessionID) continue;
        const response = config.permissionMode === "deny" ? "reject" : "always";
        await client.permission.respond({
          sessionID: permission.sessionID,
          permissionID: permission.id,
          response,
        });
        if (response === "reject") {
          const run = activeRuns.get(permission.sessionID);
          if (run) {
            await sendText(run.channel, run.peerId, "Permission denied. Update configuration to allow tools.", {
              kind: "system",
            });
          }
        }
      }
    }
  })().catch((error) => {
    logger.error({ error }, "event stream closed");
  });

  async function sendText(
    channel: ChannelName,
    peerId: string,
    text: string,
    options: { kind?: OutboundKind; display?: boolean } = {},
  ) {
    const adapter = adapters.get(channel);
    if (!adapter) return;
    const kind = options.kind ?? "system";
    logger.debug({ channel, peerId, kind, length: text.length }, "sendText requested");
    if (options.display !== false) {
      reporter?.onOutbound?.({ channel, peerId, text, kind });
    }
    const chunks = chunkText(text, adapter.maxTextLength);
    for (const chunk of chunks) {
      logger.info({ channel, peerId, length: chunk.length }, "sending message");
      await adapter.sendText(peerId, chunk);
    }
  }

  async function handleInbound(message: InboundMessage) {
    const adapter = adapters.get(message.channel);
    if (!adapter) return;
    let inbound = message;
    logger.debug(
      {
        channel: inbound.channel,
        peerId: inbound.peerId,
        fromMe: inbound.fromMe,
        length: inbound.text.length,
        preview: truncateText(inbound.text.trim(), 120),
      },
      "inbound received",
    );
    logger.info(
      { channel: inbound.channel, peerId: inbound.peerId, length: inbound.text.length },
      "received message",
    );
    const peerKey = inbound.channel === "whatsapp" ? normalizeWhatsAppId(inbound.peerId) : inbound.peerId;
    if (inbound.channel === "whatsapp") {
      if (config.whatsappDmPolicy === "disabled") {
        return;
      }

      const allowAll = config.whatsappDmPolicy === "open" || config.whatsappAllowFrom.has("*");
      const isSelf = Boolean(inbound.fromMe && config.whatsappSelfChatMode);
      const allowed = allowAll || isSelf || store.isAllowed("whatsapp", peerKey);
      logger.debug(
        { allowAll, isSelf, allowed, dmPolicy: config.whatsappDmPolicy, peerKey },
        "whatsapp allowlist check",
      );
      if (!allowed) {
        if (config.whatsappDmPolicy === "allowlist") {
          await sendText(
            inbound.channel,
            inbound.peerId,
            "Access denied. Ask the owner to allowlist your number.",
            { kind: "system" },
          );
          return;
        }

        store.prunePairingRequests();
        const active = store.getPairingRequest("whatsapp", peerKey);
        const pending = store.listPairingRequests("whatsapp");
        if (!active && pending.length >= 3) {
          await sendText(
            inbound.channel,
            inbound.peerId,
            "Pairing queue full. Ask the owner to approve pending requests.",
            { kind: "system" },
          );
          return;
        }

        const code = active?.code ?? String(Math.floor(100000 + Math.random() * 900000));
        if (!active) {
          store.createPairingRequest("whatsapp", peerKey, code, 60 * 60_000);
        }
        await sendText(
          inbound.channel,
          inbound.peerId,
          `Pairing required. Ask the owner to approve code: ${code}`,
          { kind: "system" },
        );
        return;
      }
    } else if (config.allowlist[inbound.channel].size > 0) {
      if (!store.isAllowed(inbound.channel, peerKey)) {
        logger.debug({ channel: inbound.channel, peerKey }, "telegram allowlist denied");
        await sendText(inbound.channel, inbound.peerId, "Access denied.", { kind: "system" });
        return;
      }
    }

    reporter?.onInbound?.({
      channel: inbound.channel,
      peerId: inbound.peerId,
      text: inbound.text,
      fromMe: inbound.fromMe,
    });

    const session = store.getSession(inbound.channel, peerKey);
    const sessionID = session?.session_id ?? (await createSession({ ...inbound, peerId: peerKey }));
    logger.debug(
      {
        sessionID,
        channel: inbound.channel,
        peerId: inbound.peerId,
        reused: Boolean(session?.session_id),
      },
      "session resolved",
    );

    enqueue(sessionID, async () => {
      const runState: RunState = {
        sessionID,
        channel: inbound.channel,
        peerId: inbound.peerId,
        toolUpdatesEnabled: config.toolUpdatesEnabled,
        seenToolStates: new Map(),
      };
      activeRuns.set(sessionID, runState);
      reportThinking(runState);
      startTyping(runState);
      try {
        logger.debug({ sessionID, length: inbound.text.length }, "prompt start");
        const response = await client.session.prompt({
          sessionID,
          parts: [{ type: "text", text: inbound.text }],
        });
        const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? [];
        const textParts = parts.filter((part) => part.type === "text" && !part.ignored);
        logger.debug(
          {
            sessionID,
            partCount: parts.length,
            textCount: textParts.length,
          },
          "prompt response",
        );
        const reply = parts
          .filter((part) => part.type === "text" && !part.ignored)
          .map((part) => part.text ?? "")
          .join("\n")
          .trim();

        if (reply) {
          logger.debug({ sessionID, replyLength: reply.length }, "reply built");
          await sendText(inbound.channel, inbound.peerId, reply, { kind: "reply" });
        } else {
          logger.debug({ sessionID }, "reply empty");
          await sendText(inbound.channel, inbound.peerId, "No response generated. Try again.", {
            kind: "system",
          });
        }
      } catch (error) {
        logger.error({ error }, "prompt failed");
        await sendText(inbound.channel, inbound.peerId, "Error: failed to reach OpenCode.", {
          kind: "system",
        });
      } finally {
        stopTyping(sessionID);
        reportDone(runState);
        activeRuns.delete(sessionID);
      }
    });
  }

  async function createSession(message: InboundMessage): Promise<string> {
    const title = `owpenbot ${message.channel} ${message.peerId}`;
    const session = await client.session.create({
      title,
      permission: buildPermissionRules(config.permissionMode),
    });
    const sessionID = (session as { id?: string }).id;
    if (!sessionID) throw new Error("Failed to create session");
    store.upsertSession(message.channel, message.peerId, sessionID);
    logger.info({ sessionID, channel: message.channel, peerId: message.peerId }, "session created");
    reportStatus?.(
      `${CHANNEL_LABELS[message.channel]} session created for ${formatPeer(message.channel, message.peerId)} (ID: ${sessionID}).`,
    );
    await sendText(message.channel, message.peerId, "🧭 Session started.", { kind: "system" });
    return sessionID;
  }

  function enqueue(sessionID: string, task: () => Promise<void>) {
    const previous = sessionQueue.get(sessionID) ?? Promise.resolve();
    const next = previous
      .then(task)
      .catch((error) => {
        logger.error({ error }, "session task failed");
      })
      .finally(() => {
        if (sessionQueue.get(sessionID) === next) {
          sessionQueue.delete(sessionID);
        }
      });
    sessionQueue.set(sessionID, next);
  }

  for (const adapter of adapters.values()) {
    await adapter.start();
    reportStatus?.(`${adapter.name === "whatsapp" ? "WhatsApp" : "Telegram"} adapter started.`);
  }

  logger.info({ channels: Array.from(adapters.keys()) }, "bridge started");
  reportStatus?.(`Bridge running. Logs: ${config.logFile}`);

  return {
    async stop() {
      eventAbort.abort();
      clearInterval(healthTimer);
      if (stopHealthServer) stopHealthServer();
      for (const timer of typingLoops.values()) {
        clearInterval(timer);
      }
      typingLoops.clear();
      for (const adapter of adapters.values()) {
        await adapter.stop();
      }
      store.close();
      await delay(50);
    },
  };
}



================================================
FILE: packages/owpenbot/src/cli.ts
================================================
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";

import { Command } from "commander";

import { startBridge, type BridgeReporter } from "./bridge.js";
import {
  loadConfig,
  normalizeWhatsAppId,
  readConfigFile,
  writeConfigFile,
  type ChannelName,
  type OwpenbotConfigFile,
} from "./config.js";
import { BridgeStore } from "./db.js";
import { createLogger } from "./logger.js";
import { createClient } from "./opencode.js";
import { truncateText } from "./text.js";
import { loginWhatsApp, unpairWhatsApp } from "./whatsapp.js";
import { hasWhatsAppCreds } from "./whatsapp-session.js";

const VERSION = "0.1.16";

// -----------------------------------------------------------------------------
// JSON output helpers
// -----------------------------------------------------------------------------

function outputJson(data: unknown) {
  console.log(JSON.stringify(data, null, 2));
}

function outputError(message: string, exitCode = 1): never {
  if (program.opts().json) {
    outputJson({ error: message });
  } else {
    console.error(`Error: ${message}`);
  }
  process.exit(exitCode);
}

// -----------------------------------------------------------------------------
// App logger and console reporter for start command
// -----------------------------------------------------------------------------

function createAppLogger(config: ReturnType<typeof loadConfig>) {
  return createLogger(config.logLevel, { logFile: config.logFile });
}

function createConsoleReporter(): BridgeReporter {
  const formatChannel = (channel: ChannelName) => (channel === "whatsapp" ? "WhatsApp" : "Telegram");
  const formatPeer = (channel: ChannelName, peerId: string, fromMe?: boolean) => {
    const base = channel === "whatsapp" ? normalizeWhatsAppId(peerId) : peerId;
    return fromMe ? `${base} (me)` : base;
  };

  const printBlock = (prefix: string, text: string) => {
    const lines = text.split(/\r?\n/).map((line) => truncateText(line.trim(), 240));
    const [first, ...rest] = lines.length ? lines : ["(empty)"];
    console.log(`${prefix} ${first}`);
    for (const line of rest) {
      console.log(`${" ".repeat(prefix.length)} ${line}`);
    }
  };

  return {
    onStatus(message) {
      console.log(message);
    },
    onInbound({ channel, peerId, text, fromMe }) {
      const prefix = `[${formatChannel(channel)}] ${formatPeer(channel, peerId, fromMe)} >`;
      printBlock(prefix, text);
    },
    onOutbound({ channel, peerId, text, kind }) {
      const marker = kind === "reply" ? "<" : kind === "tool" ? "*" : "!";
      const prefix = `[${formatChannel(channel)}] ${formatPeer(channel, peerId)} ${marker}`;
      printBlock(prefix, text);
    },
  };
}

// -----------------------------------------------------------------------------
// Config helpers
// -----------------------------------------------------------------------------

function updateConfig(configPath: string, updater: (cfg: OwpenbotConfigFile) => OwpenbotConfigFile) {
  const { config } = readConfigFile(configPath);
  const base = config ?? { version: 1 };
  const next = updater(base);
  next.version = next.version ?? 1;
  writeConfigFile(configPath, next);
  return next;
}

function getNestedValue(obj: Record<string, unknown>, keyPath: string): unknown {
  const keys = keyPath.split(".");
  let current: unknown = obj;
  for (const key of keys) {
    if (current === null || current === undefined || typeof current !== "object") {
      return undefined;
    }
    current = (current as Record<string, unknown>)[key];
  }
  return current;
}

function setNestedValue(obj: Record<string, unknown>, keyPath: string, value: unknown): void {
  const keys = keyPath.split(".");
  let current = obj;
  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i];
    if (current[key] === undefined || current[key] === null || typeof current[key] !== "object") {
      current[key] = {};
    }
    current = current[key] as Record<string, unknown>;
  }
  current[keys[keys.length - 1]] = value;
}

function parseConfigValue(value: string): unknown {
  // Try to parse as JSON first (for arrays, objects, booleans, numbers)
  try {
    return JSON.parse(value);
  } catch {
    // Return as string if not valid JSON
    return value;
  }
}

// -----------------------------------------------------------------------------
// Start command
// -----------------------------------------------------------------------------

async function runStart(pathOverride?: string, options?: { opencodeUrl?: string }) {
  if (pathOverride?.trim()) {
    process.env.OPENCODE_DIRECTORY = pathOverride.trim();
  }
  if (options?.opencodeUrl?.trim()) {
    process.env.OPENCODE_URL = options.opencodeUrl.trim();
  }
  const config = loadConfig();
  const logger = createAppLogger(config);
  const reporter = createConsoleReporter();
  if (!process.env.OPENCODE_DIRECTORY) {
    process.env.OPENCODE_DIRECTORY = config.opencodeDirectory;
  }
  const bridge = await startBridge(config, logger, reporter);
  reporter.onStatus?.("Commands: owpenwork whatsapp login, owpenwork pairing list, owpenwork status");

  const shutdown = async () => {
    logger.info("shutting down");
    await bridge.stop();
    process.exit(0);
  };

  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);
}

// -----------------------------------------------------------------------------
// QR code generation for non-interactive use
// -----------------------------------------------------------------------------

async function getWhatsAppQr(config: ReturnType<typeof loadConfig>, format: "ascii" | "base64"): Promise<string> {
  const { createWhatsAppSocket, closeWhatsAppSocket } = await import("./whatsapp-session.js");
  const logger = createAppLogger(config);
  
  return new Promise((resolve, reject) => {
    let resolved = false;
    const timeout = setTimeout(() => {
      if (!resolved) {
        resolved = true;
        reject(new Error("Timeout waiting for QR code"));
      }
    }, 30000);

    void createWhatsAppSocket({
      authDir: config.whatsappAuthDir,
      logger,
      printQr: false,
      onQr: (qr) => {
        if (resolved) return;
        resolved = true;
        clearTimeout(timeout);
        
        if (format === "base64") {
          resolve(Buffer.from(qr).toString("base64"));
        } else {
          // Generate ASCII QR using qrcode-terminal's internal logic
          // For simplicity, return the raw QR data - consumers can render it
          resolve(qr);
        }
      },
    }).then((sock) => {
      // Close socket after getting QR or on timeout
      setTimeout(() => {
        closeWhatsAppSocket(sock);
      }, resolved ? 500 : 30500);
    }).catch((err) => {
      if (!resolved) {
        resolved = true;
        clearTimeout(timeout);
        reject(err);
      }
    });
  });
}

// -----------------------------------------------------------------------------
// Commander setup
// -----------------------------------------------------------------------------

const program = new Command();

program
  .name("owpenbot")
  .version(VERSION)
  .description("OpenCode WhatsApp + Telegram bridge")
  .option("--json", "Output in JSON format", false);

// -----------------------------------------------------------------------------
// start command
// -----------------------------------------------------------------------------

program
  .command("start")
  .description("Start the bridge")
  .argument("[path]", "OpenCode workspace path")
  .option("--opencode-url <url>", "OpenCode server URL")
  .action((pathArg?: string, options?: { opencodeUrl?: string }) => runStart(pathArg, options));

// -----------------------------------------------------------------------------
// health command
// -----------------------------------------------------------------------------

program
  .command("health")
  .description("Check bridge health (exit 0 if healthy, 1 if not)")
  .action(async () => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    
    try {
      const client = createClient(config);
      const health = await client.global.health();
      const healthy = Boolean((health as { healthy?: boolean }).healthy);
      
      if (useJson) {
        outputJson({
          healthy,
          opencodeUrl: config.opencodeUrl,
          channels: {
            whatsapp: hasWhatsAppCreds(config.whatsappAuthDir) ? "linked" : "unlinked",
            telegram: config.telegramToken ? "configured" : "unconfigured",
          },
        });
      } else {
        console.log(`Healthy: ${healthy ? "yes" : "no"}`);
        console.log(`OpenCode URL: ${config.opencodeUrl}`);
      }
      
      process.exit(healthy ? 0 : 1);
    } catch (error) {
      if (useJson) {
        outputJson({
          healthy: false,
          error: String(error),
          opencodeUrl: config.opencodeUrl,
          channels: {
            whatsapp: hasWhatsAppCreds(config.whatsappAuthDir) ? "linked" : "unlinked",
            telegram: config.telegramToken ? "configured" : "unconfigured",
          },
        });
      } else {
        console.log("Healthy: no");
        console.log(`Error: ${String(error)}`);
      }
      process.exit(1);
    }
  });

// -----------------------------------------------------------------------------
// status command
// -----------------------------------------------------------------------------

program
  .command("status")
  .description("Show WhatsApp, Telegram, and OpenCode status")
  .action(async () => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const whatsappLinked = hasWhatsAppCreds(config.whatsappAuthDir);
    
    if (useJson) {
      outputJson({
        config: config.configPath,
        whatsapp: {
          linked: whatsappLinked,
          dmPolicy: config.whatsappDmPolicy,
          selfChatMode: config.whatsappSelfChatMode,
          authDir: config.whatsappAuthDir,
        },
        telegram: {
          configured: Boolean(config.telegramToken),
          enabled: config.telegramEnabled,
        },
        opencode: {
          url: config.opencodeUrl,
          directory: config.opencodeDirectory,
        },
      });
    } else {
      console.log(`Config: ${config.configPath}`);
      console.log(`WhatsApp linked: ${whatsappLinked ? "yes" : "no"}`);
      console.log(`WhatsApp DM policy: ${config.whatsappDmPolicy}`);
      console.log(`Telegram configured: ${config.telegramToken ? "yes" : "no"}`);
      console.log(`Auth dir: ${config.whatsappAuthDir}`);
      console.log(`OpenCode URL: ${config.opencodeUrl}`);
    }
  });

// -----------------------------------------------------------------------------
// config subcommand
// -----------------------------------------------------------------------------

const configCmd = program.command("config").description("Manage configuration");

configCmd
  .command("get")
  .argument("[key]", "Config key to get (dot notation, e.g., channels.whatsapp.dmPolicy)")
  .description("Get config value(s)")
  .action((key?: string) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const { config: configFile } = readConfigFile(config.configPath);
    
    if (key) {
      const value = getNestedValue(configFile as Record<string, unknown>, key);
      if (useJson) {
        outputJson({ [key]: value });
      } else {
        if (value === undefined) {
          console.log(`${key}: (not set)`);
        } else if (typeof value === "object") {
          console.log(`${key}: ${JSON.stringify(value, null, 2)}`);
        } else {
          console.log(`${key}: ${value}`);
        }
      }
    } else {
      if (useJson) {
        outputJson(configFile);
      } else {
        console.log(JSON.stringify(configFile, null, 2));
      }
    }
  });

configCmd
  .command("set")
  .argument("<key>", "Config key to set (dot notation)")
  .argument("<value>", "Value to set (JSON for arrays/objects)")
  .description("Set config value")
  .action((key: string, value: string) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    
    const parsedValue = parseConfigValue(value);
    const updated = updateConfig(config.configPath, (cfg) => {
      const next = { ...cfg } as Record<string, unknown>;
      setNestedValue(next, key, parsedValue);
      return next as OwpenbotConfigFile;
    });
    
    if (useJson) {
      outputJson({ success: true, key, value: parsedValue, config: updated });
    } else {
      console.log(`Set ${key} = ${JSON.stringify(parsedValue)}`);
    }
  });

// -----------------------------------------------------------------------------
// whatsapp subcommand
// -----------------------------------------------------------------------------

const whatsapp = program.command("whatsapp").description("WhatsApp helpers");

whatsapp
  .command("status")
  .description("Show WhatsApp status")
  .action(() => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const linked = hasWhatsAppCreds(config.whatsappAuthDir);
    
    if (useJson) {
      outputJson({
        linked,
        dmPolicy: config.whatsappDmPolicy,
        selfChatMode: config.whatsappSelfChatMode,
        authDir: config.whatsappAuthDir,
        accountId: config.whatsappAccountId,
        allowFrom: [...config.whatsappAllowFrom],
      });
    } else {
      console.log(`WhatsApp linked: ${linked ? "yes" : "no"}`);
      console.log(`DM policy: ${config.whatsappDmPolicy}`);
      console.log(`Self chat mode: ${config.whatsappSelfChatMode ? "yes" : "no"}`);
      console.log(`Auth dir: ${config.whatsappAuthDir}`);
    }
  });

whatsapp
  .command("login")
  .description("Login to WhatsApp via QR code")
  .action(async () => {
    const config = loadConfig(process.env, { requireOpencode: false });
    await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
  });

whatsapp
  .command("logout")
  .description("Logout of WhatsApp and clear auth state")
  .action(() => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    unpairWhatsApp(config, createAppLogger(config));
    
    if (useJson) {
      outputJson({ success: true, message: "WhatsApp auth cleared" });
    } else {
      console.log("WhatsApp auth cleared.");
    }
  });

whatsapp
  .command("qr")
  .description("Get WhatsApp QR code non-interactively")
  .option("--format <format>", "Output format: ascii or base64", "ascii")
  .action(async (opts) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const format = opts.format as "ascii" | "base64";
    
    if (hasWhatsAppCreds(config.whatsappAuthDir)) {
      if (useJson) {
        outputJson({ error: "WhatsApp already linked. Use 'whatsapp logout' first." });
      } else {
        console.log("WhatsApp already linked. Use 'whatsapp logout' first.");
      }
      process.exit(1);
    }
    
    try {
      const qr = await getWhatsAppQr(config, format);
      
      if (useJson) {
        outputJson({ qr, format });
      } else {
        if (format === "ascii") {
          // Use qrcode-terminal to print ASCII QR
          const qrcode = await import("qrcode-terminal");
          qrcode.default.generate(qr, { small: true });
        } else {
          console.log(qr);
        }
      }
    } catch (error) {
      if (useJson) {
        outputJson({ error: String(error) });
      } else {
        console.error(`Failed to get QR code: ${String(error)}`);
      }
      process.exit(1);
    }
  });

// -----------------------------------------------------------------------------
// telegram subcommand
// -----------------------------------------------------------------------------

const telegram = program.command("telegram").description("Telegram helpers");

telegram
  .command("status")
  .description("Show Telegram status")
  .action(() => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    
    if (useJson) {
      outputJson({
        configured: Boolean(config.telegramToken),
        enabled: config.telegramEnabled,
        hasToken: Boolean(config.telegramToken),
      });
    } else {
      console.log(`Telegram configured: ${config.telegramToken ? "yes" : "no"}`);
      console.log(`Telegram enabled: ${config.telegramEnabled ? "yes" : "no"}`);
    }
  });

telegram
  .command("set-token")
  .argument("<token>", "Telegram bot token")
  .description("Set Telegram bot token")
  .action((token: string) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    
    updateConfig(config.configPath, (cfg) => {
      const next = { ...cfg } as OwpenbotConfigFile;
      next.channels = next.channels ?? {};
      next.channels.telegram = {
        token,
        enabled: true,
      };
      return next;
    });
    
    if (useJson) {
      outputJson({ success: true, message: "Telegram token saved" });
    } else {
      console.log("Telegram token saved.");
    }
  });

// -----------------------------------------------------------------------------
// pairing subcommand
// -----------------------------------------------------------------------------

const pairing = program.command("pairing").description("Pairing requests");

pairing
  .command("list")
  .description("List pending pairing requests")
  .action(() => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const store = new BridgeStore(config.dbPath);
    store.prunePairingRequests();
    const requests = store.listPairingRequests();
    store.close();
    
    if (useJson) {
      outputJson(
        requests.map((r) => ({
          code: r.code,
          peerId: r.peer_id,
          channel: r.channel,
          createdAt: new Date(r.created_at).toISOString(),
          expiresAt: new Date(r.expires_at).toISOString(),
        })),
      );
    } else {
      if (!requests.length) {
        console.log("No pending pairing requests.");
      } else {
        for (const request of requests) {
          console.log(`${request.code} ${request.channel} ${request.peer_id}`);
        }
      }
    }
  });

pairing
  .command("approve")
  .argument("<code>", "Pairing code to approve")
  .description("Approve a pairing request")
  .action((code: string) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const store = new BridgeStore(config.dbPath);
    const request = store.approvePairingRequest("whatsapp", code.trim());
    
    if (!request) {
      store.close();
      if (useJson) {
        outputJson({ success: false, error: "Pairing code not found or expired" });
      } else {
        console.log("Pairing code not found or expired.");
      }
      process.exit(1);
    }
    
    store.allowPeer("whatsapp", request.peer_id);
    store.close();
    
    if (useJson) {
      outputJson({ success: true, peerId: request.peer_id, channel: request.channel });
    } else {
      console.log(`Approved ${request.peer_id}`);
    }
  });

pairing
  .command("deny")
  .argument("<code>", "Pairing code to deny")
  .description("Deny a pairing request")
  .action((code: string) => {
    const useJson = program.opts().json;
    const config = loadConfig(process.env, { requireOpencode: false });
    const store = new BridgeStore(config.dbPath);
    const ok = store.denyPairingRequest("whatsapp", code.trim());
    store.close();
    
    if (useJson) {
      outputJson({ success: ok, message: ok ? "Pairing request removed" : "Pairing code not found" });
    } else {
      console.log(ok ? "Removed pairing request." : "Pairing code not found.");
    }
    
    process.exit(ok ? 0 : 1);
  });

// -----------------------------------------------------------------------------
// Legacy commands for backwards compatibility
// -----------------------------------------------------------------------------

program
  .command("qr")
  .description("Print a WhatsApp QR code to pair (alias for whatsapp qr)")
  .action(async () => {
    const config = loadConfig(process.env, { requireOpencode: false });
    await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
  });

program
  .command("unpair")
  .description("Clear WhatsApp pairing data (alias for whatsapp logout)")
  .action(() => {
    const config = loadConfig(process.env, { requireOpencode: false });
    unpairWhatsApp(config, createAppLogger(config));
  });

// login subcommand for backwards compatibility
const login = program.command("login").description("Link channels (legacy)");

login
  .command("whatsapp")
  .description("Login to WhatsApp via QR code")
  .action(async () => {
    const config = loadConfig(process.env, { requireOpencode: false });
    await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
  });

login
  .command("telegram")
  .option("--token <token>", "Telegram bot token")
  .description("Save Telegram bot token")
  .action((opts) => {
    if (!opts.token) {
      console.error("Error: --token is required");
      process.exit(1);
    }
    const config = loadConfig(process.env, { requireOpencode: false });
    updateConfig(config.configPath, (cfg) => {
      const next = { ...cfg } as OwpenbotConfigFile;
      next.channels = next.channels ?? {};
      next.channels.telegram = {
        token: opts.token,
        enabled: true,
      };
      return next;
    });
    console.log("Telegram token saved.");
  });

// pairing-code (legacy alias)
program
  .command("pairing-code")
  .description("List pending pairing codes (alias for pairing list)")
  .action(() => {
    const config = loadConfig(process.env, { requireOpencode: false });
    const store = new BridgeStore(config.dbPath);
    store.prunePairingRequests();
    const requests = store.listPairingRequests("whatsapp");
    if (!requests.length) {
      console.log("No pending pairing requests.");
    } else {
      for (const request of requests) {
        console.log(`${request.code} ${request.peer_id}`);
      }
    }
    store.close();
  });

// -----------------------------------------------------------------------------
// send command
// -----------------------------------------------------------------------------

program
  .command("send")
  .description("Send a test message")
  .requiredOption("--channel <channel>", "Channel: whatsapp or telegram")
  .requiredOption("--to <recipient>", "Recipient ID (phone number or chat ID)")
  .requiredOption("--message <text>", "Message text to send")
  .action(async (opts) => {
    const useJson = program.opts().json;
    const channel = opts.channel as string;
    const to = opts.to as string;
    const message = opts.message as string;

    if (channel !== "whatsapp" && channel !== "telegram") {
      if (useJson) {
        outputJson({ success: false, error: `Invalid channel: ${channel}. Must be 'whatsapp' or 'telegram'.` });
      } else {
        console.error(`Error: Invalid channel '${channel}'. Must be 'whatsapp' or 'telegram'.`);
      }
      process.exit(1);
    }

    const config = loadConfig(process.env, { requireOpencode: false });
    const logger = createAppLogger(config);

    try {
      if (channel === "whatsapp") {
        const { createWhatsAppAdapter } = await import("./whatsapp.js");
        const adapter = createWhatsAppAdapter(config, logger, async () => {}, { printQr: false });
        await adapter.start();
        
        // Format the recipient ID for WhatsApp
        let peerId = to.trim();
        if (!peerId.includes("@")) {
          // Remove + prefix if present and add WhatsApp suffix
          const cleaned = peerId.startsWith("+") ? peerId.slice(1) : peerId;
          peerId = `${cleaned}@s.whatsapp.net`;
        }
        
        await adapter.sendText(peerId, message);
        await adapter.stop();
        
        if (useJson) {
          outputJson({ success: true, channel, to: peerId, message });
        } else {
          console.log(`Message sent to ${peerId} via WhatsApp`);
        }
      } else {
        const { createTelegramAdapter } = await import("./telegram.js");
        const adapter = createTelegramAdapter(config, logger, async () => {});
        // Note: Telegram adapter's start() begins long-polling, we just need to send
        // Use the bot API directly for a one-shot send
        const { Bot } = await import("grammy");
        if (!config.telegramToken) {
          throw new Error("Telegram bot token not configured. Use 'owpenbot telegram set-token <token>' first.");
        }
        const bot = new Bot(config.telegramToken);
        await bot.api.sendMessage(Number(to), message);
        
        if (useJson) {
          outputJson({ success: true, channel, to, message });
        } else {
          console.log(`Message sent to ${to} via Telegram`);
        }
      }
      process.exit(0);
    } catch (error) {
      if (useJson) {
        outputJson({ success: false, error: String(error) });
      } else {
        console.error(`Failed to send message: ${String(error)}`);
      }
      process.exit(1);
    }
  });

// -----------------------------------------------------------------------------
// Default action (no subcommand)
// -----------------------------------------------------------------------------

program.action(() => {
  program.outputHelp();
});

// -----------------------------------------------------------------------------
// Parse and run
// -----------------------------------------------------------------------------

program.parseAsync(process.argv).catch((error) => {
  const useJson = program.opts().json;
  if (useJson) {
    outputJson({ error: String(error) });
  } else {
    console.error(error);
  }
  process.exitCode = 1;
});



================================================
FILE: packages/owpenbot/src/config.ts
================================================
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import dotenv from "dotenv";

const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(moduleDir, "..");
dotenv.config({ path: path.join(packageDir, ".env") });
dotenv.config();

export type ChannelName = "telegram" | "whatsapp";

export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";

export type OwpenbotConfigFile = {
  version: number;
  opencodeUrl?: string;
  opencodeDirectory?: string;
  channels?: {
    whatsapp?: {
      dmPolicy?: DmPolicy;
      allowFrom?: string[];
      selfChatMode?: boolean;
      accounts?: Record<
        string,
        {
          authDir?: string;
          sendReadReceipts?: boolean;
        }
      >;
    };
    telegram?: {
      token?: string;
      enabled?: boolean;
    };
  };
};

export type Config = {
  configPath: string;
  configFile: OwpenbotConfigFile;
  opencodeUrl: string;
  opencodeDirectory: string;
  opencodeUsername?: string;
  opencodePassword?: string;
  telegramToken?: string;
  telegramEnabled: boolean;
  whatsappAuthDir: string;
  whatsappAccountId: string;
  whatsappDmPolicy: DmPolicy;
  whatsappAllowFrom: Set<string>;
  whatsappSelfChatMode: boolean;
  whatsappEnabled: boolean;
  dataDir: string;
  dbPath: string;
  logFile: string;
  allowlist: Record<ChannelName, Set<string>>;
  toolUpdatesEnabled: boolean;
  groupsEnabled: boolean;
  permissionMode: "allow" | "deny";
  toolOutputLimit: number;
  healthPort?: number;
  logLevel: string;
};

type EnvLike = NodeJS.ProcessEnv;

function parseBoolean(value: string | undefined, fallback: boolean): boolean {
  if (value === undefined) return fallback;
  return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}

function parseInteger(value: string | undefined): number | undefined {
  if (!value) return undefined;
  const parsed = Number.parseInt(value, 10);
  return Number.isFinite(parsed) ? parsed : undefined;
}

function parseList(value: string | undefined): string[] {
  if (!value) return [];
  return value
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function expandHome(value: string): string {
  if (!value.startsWith("~/")) return value;
  return path.join(os.homedir(), value.slice(2));
}

export function normalizeWhatsAppId(value: string): string {
  const trimmed = value.trim();
  if (!trimmed) return trimmed;
  if (trimmed.endsWith("@g.us")) return trimmed;
  const base = trimmed.replace(/@s\.whatsapp\.net$/i, "");
  if (base.startsWith("+")) return base;
  if (/^\d+$/.test(base)) return `+${base}`;
  return base;
}

function normalizeWhatsAppAllowFrom(list: string[]): Set<string> {
  const set = new Set<string>();
  for (const entry of list) {
    const trimmed = entry.trim();
    if (!trimmed) continue;
    if (trimmed === "*") {
      set.add("*");
      continue;
    }
    set.add(normalizeWhatsAppId(trimmed));
  }
  return set;
}

function normalizeDmPolicy(value: unknown): DmPolicy {
  if (value === "allowlist" || value === "open" || value === "disabled" || value === "pairing") {
    return value;
  }
  return "pairing";
}

function resolveConfigPath(dataDir: string, env: EnvLike): string {
  const override = env.OWPENBOT_CONFIG_PATH?.trim();
  if (override) return expandHome(override);
  return path.join(dataDir, "owpenbot.json");
}

export function readConfigFile(configPath: string): { exists: boolean; config: OwpenbotConfigFile } {
  try {
    const raw = fs.readFileSync(configPath, "utf-8");
    const parsed = JSON.parse(raw) as OwpenbotConfigFile;
    return { exists: true, config: parsed };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return { exists: false, config: { version: 1 } };
    }
    throw error;
  }
}

export function writeConfigFile(configPath: string, config: OwpenbotConfigFile) {
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
}

function parseAllowlist(env: EnvLike): Record<ChannelName, Set<string>> {
  const allowlist: Record<ChannelName, Set<string>> = {
    telegram: new Set<string>(),
    whatsapp: new Set<string>(),
  };

  const shared = parseList(env.ALLOW_FROM);
  for (const entry of shared) {
    if (entry.includes(":")) {
      const [channel, peer] = entry.split(":");
      const normalized = channel.trim().toLowerCase();
      if (normalized === "telegram" || normalized === "whatsapp") {
        if (peer?.trim()) {
          allowlist[normalized].add(peer.trim());
        }
      }
    } else {
      allowlist.telegram.add(entry);
      allowlist.whatsapp.add(entry);
    }
  }

  for (const entry of parseList(env.ALLOW_FROM_TELEGRAM)) {
    allowlist.telegram.add(entry);
  }
  for (const entry of parseList(env.ALLOW_FROM_WHATSAPP)) {
    allowlist.whatsapp.add(entry);
  }

  return allowlist;
}

export function loadConfig(
  env: EnvLike = process.env,
  options: { requireOpencode?: boolean } = {},
): Config {
  const requireOpencode = options.requireOpencode ?? false;

  const dataDir = expandHome(env.OWPENBOT_DATA_DIR ?? "~/.owpenbot");
  const dbPath = expandHome(env.OWPENBOT_DB_PATH ?? path.join(dataDir, "owpenbot.db"));
  const logFile = expandHome(env.OWPENBOT_LOG_FILE ?? path.join(dataDir, "logs", "owpenbot.log"));
  const configPath = resolveConfigPath(dataDir, env);
  const { config: configFile } = readConfigFile(configPath);
  const opencodeDirectory = env.OPENCODE_DIRECTORY?.trim() || configFile.opencodeDirectory || "";
  if (!opencodeDirectory && requireOpencode) {
    throw new Error("OPENCODE_DIRECTORY is required");
  }
  const resolvedDirectory = opencodeDirectory || process.cwd();
  const whatsappFile = configFile.channels?.whatsapp ?? {};
  const whatsappAccountId = env.WHATSAPP_ACCOUNT_ID?.trim() || "default";
  const accountAuthDir = whatsappFile.accounts?.[whatsappAccountId]?.authDir;
  const whatsappAuthDir = expandHome(
    env.WHATSAPP_AUTH_DIR ??
      accountAuthDir ??
      path.join(dataDir, "credentials", "whatsapp", whatsappAccountId),
  );
  const dmPolicy = normalizeDmPolicy(
    env.WHATSAPP_DM_POLICY?.trim().toLowerCase() ?? whatsappFile.dmPolicy,
  );
  const selfChatMode = parseBoolean(env.WHATSAPP_SELF_CHAT, whatsappFile.selfChatMode ?? false);
  const envAllowlist = parseAllowlist(env);
  const fileAllowFrom = normalizeWhatsAppAllowFrom(whatsappFile.allowFrom ?? []);
  const envAllowFrom = normalizeWhatsAppAllowFrom(
    envAllowlist.whatsapp.size ? [...envAllowlist.whatsapp] : [],
  );
  const whatsappAllowFrom = new Set<string>([...fileAllowFrom, ...envAllowFrom]);

  const toolOutputLimit = parseInteger(env.TOOL_OUTPUT_LIMIT) ?? 1200;
  const permissionMode = env.PERMISSION_MODE?.toLowerCase() === "deny" ? "deny" : "allow";

  const telegramToken = env.TELEGRAM_BOT_TOKEN?.trim() || configFile.channels?.telegram?.token || undefined;

  return {
    configPath,
    configFile,
    opencodeUrl: env.OPENCODE_URL?.trim() || configFile.opencodeUrl || "http://127.0.0.1:4096",
    opencodeDirectory: resolvedDirectory,
    opencodeUsername: env.OPENCODE_SERVER_USERNAME?.trim() || undefined,
    opencodePassword: env.OPENCODE_SERVER_PASSWORD?.trim() || undefined,
    telegramToken,
    telegramEnabled: parseBoolean(
      env.TELEGRAM_ENABLED,
      configFile.channels?.telegram?.enabled ?? Boolean(telegramToken),
    ),
    whatsappAuthDir,
    whatsappAccountId,
    whatsappDmPolicy: dmPolicy,
    whatsappAllowFrom,
    whatsappSelfChatMode: selfChatMode,
    whatsappEnabled: parseBoolean(env.WHATSAPP_ENABLED, true),
    dataDir,
    dbPath,
    logFile,
    allowlist: envAllowlist,
    toolUpdatesEnabled: parseBoolean(env.TOOL_UPDATES_ENABLED, false),
    groupsEnabled: parseBoolean(env.GROUPS_ENABLED, false),
    permissionMode,
    toolOutputLimit,
    healthPort: parseInteger(env.OWPENBOT_HEALTH_PORT),
    logLevel: env.LOG_LEVEL?.trim() || "info",
  };
}



================================================
FILE: packages/owpenbot/src/db.ts
================================================
import fs from "node:fs";
import path from "node:path";

import { Database } from "bun:sqlite";

import type { ChannelName } from "./config.js";

type SessionRow = {
  channel: ChannelName;
  peer_id: string;
  session_id: string;
  created_at: number;
  updated_at: number;
};

type AllowlistRow = {
  channel: ChannelName;
  peer_id: string;
  created_at: number;
};

type PairingRow = {
  channel: ChannelName;
  peer_id: string;
  code: string;
  created_at: number;
  expires_at: number;
};

export class BridgeStore {
  private db: Database;

  constructor(private readonly dbPath: string) {
    this.ensureDir();
    this.db = new Database(dbPath, { create: true });
    this.db.exec("PRAGMA journal_mode = WAL");
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS sessions (
        channel TEXT NOT NULL,
        peer_id TEXT NOT NULL,
        session_id TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        PRIMARY KEY (channel, peer_id)
      );
      CREATE TABLE IF NOT EXISTS allowlist (
        channel TEXT NOT NULL,
        peer_id TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        PRIMARY KEY (channel, peer_id)
      );
      CREATE TABLE IF NOT EXISTS settings (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );
      CREATE TABLE IF NOT EXISTS pairing_requests (
        channel TEXT NOT NULL,
        peer_id TEXT NOT NULL,
        code TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        expires_at INTEGER NOT NULL,
        PRIMARY KEY (channel, peer_id)
      );
    `);
  }

  private ensureDir() {
    const dir = path.dirname(this.dbPath);
    fs.mkdirSync(dir, { recursive: true });
  }

  getSession(channel: ChannelName, peerId: string): SessionRow | null {
    const stmt = this.db.prepare(
      "SELECT channel, peer_id, session_id, created_at, updated_at FROM sessions WHERE channel = ? AND peer_id = ?",
    );
    const row = stmt.get(channel, peerId) as SessionRow | null;
    return row ?? null;
  }

  upsertSession(channel: ChannelName, peerId: string, sessionId: string) {
    const now = Date.now();
    const stmt = this.db.prepare(
      `INSERT INTO sessions (channel, peer_id, session_id, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?)
       ON CONFLICT(channel, peer_id) DO UPDATE SET session_id = excluded.session_id, updated_at = excluded.updated_at`,
    );
    stmt.run(channel, peerId, sessionId, now, now);
  }

  isAllowed(channel: ChannelName, peerId: string): boolean {
    const stmt = this.db.prepare(
      "SELECT channel, peer_id, created_at FROM allowlist WHERE channel = ? AND peer_id = ?",
    );
    return Boolean(stmt.get(channel, peerId));
  }

  allowPeer(channel: ChannelName, peerId: string) {
    const now = Date.now();
    const stmt = this.db.prepare(
      `INSERT INTO allowlist (channel, peer_id, created_at)
       VALUES (?, ?, ?)
       ON CONFLICT(channel, peer_id) DO UPDATE SET created_at = excluded.created_at`,
    );
    stmt.run(channel, peerId, now);
  }

  seedAllowlist(channel: ChannelName, peers: Iterable<string>) {
    const insert = this.db.prepare(
      `INSERT INTO allowlist (channel, peer_id, created_at)
       VALUES (?, ?, ?)
       ON CONFLICT(channel, peer_id) DO NOTHING`,
    );
    const now = Date.now();
    const transaction = this.db.transaction(() => {
      for (const peer of peers) {
        insert.run(channel, peer, now);
      }
    });
    transaction();
  }

  listPairingRequests(channel?: ChannelName): PairingRow[] {
    const now = Date.now();
    if (channel) {
      const stmt = this.db.prepare(
        "SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND expires_at > ? ORDER BY created_at ASC",
      );
      return stmt.all(channel, now) as PairingRow[];
    }
    const stmt = this.db.prepare(
      "SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE expires_at > ? ORDER BY created_at ASC",
    );
    return stmt.all(now) as PairingRow[];
  }

  getPairingRequest(channel: ChannelName, peerId: string): PairingRow | null {
    const now = Date.now();
    const stmt = this.db.prepare(
      "SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND peer_id = ? AND expires_at > ?",
    );
    const row = stmt.get(channel, peerId, now) as PairingRow | null;
    return row ?? null;
  }

  createPairingRequest(channel: ChannelName, peerId: string, code: string, ttlMs: number) {
    const now = Date.now();
    const expiresAt = now + ttlMs;
    const stmt = this.db.prepare(
      `INSERT INTO pairing_requests (channel, peer_id, code, created_at, expires_at)
       VALUES (?, ?, ?, ?, ?)
       ON CONFLICT(channel, peer_id) DO UPDATE SET code = excluded.code, created_at = excluded.created_at, expires_at = excluded.expires_at`,
    );
    stmt.run(channel, peerId, code, now, expiresAt);
  }

  approvePairingRequest(channel: ChannelName, code: string): PairingRow | null {
    const now = Date.now();
    const select = this.db.prepare(
      "SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND code = ? AND expires_at > ?",
    );
    const row = select.get(channel, code, now) as PairingRow | null;
    if (!row) return null;
    const del = this.db.prepare("DELETE FROM pairing_requests WHERE channel = ? AND peer_id = ?");
    del.run(channel, row.peer_id);
    return row;
  }

  denyPairingRequest(channel: ChannelName, code: string): boolean {
    const stmt = this.db.prepare("DELETE FROM pairing_requests WHERE channel = ? AND code = ?");
    const result = stmt.run(channel, code);
    return result.changes > 0;
  }

  prunePairingRequests() {
    const now = Date.now();
    const stmt = this.db.prepare("DELETE FROM pairing_requests WHERE expires_at <= ?");
    stmt.run(now);
  }

  getSetting(key: string): string | null {
    const stmt = this.db.prepare("SELECT value FROM settings WHERE key = ?");
    const row = stmt.get(key) as { value?: string } | null;
    return row?.value ?? null;
  }

  setSetting(key: string, value: string) {
    const stmt = this.db.prepare(
      "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
    );
    stmt.run(key, value);
  }

  close() {
    this.db.close();
  }
}



================================================
FILE: packages/owpenbot/src/events.ts
================================================
type RawEvent = {
  type?: string;
  properties?: unknown;
  payload?: { type?: string; properties?: unknown };
};

export type NormalizedEvent = {
  type: string;
  properties?: any;
};

export function normalizeEvent(raw: RawEvent | null | undefined): NormalizedEvent | null {
  if (!raw) return null;
  if (typeof raw.type === "string") {
    return { type: raw.type, properties: raw.properties };
  }
  if (raw.payload && typeof raw.payload.type === "string") {
    return { type: raw.payload.type, properties: raw.payload.properties };
  }
  return null;
}



================================================
FILE: packages/owpenbot/src/health.ts
================================================
import http from "node:http";

import type { Logger } from "pino";

export type HealthSnapshot = {
  ok: boolean;
  opencode: {
    url: string;
    healthy: boolean;
    version?: string;
  };
  channels: {
    telegram: boolean;
    whatsapp: boolean;
  };
};

export function startHealthServer(
  port: number,
  getStatus: () => HealthSnapshot,
  logger: Logger,
) {
  const server = http.createServer((req, res) => {
    if (!req.url || req.url === "/health") {
      const snapshot = getStatus();
      res.writeHead(snapshot.ok ? 200 : 503, {
        "Content-Type": "application/json",
      });
      res.end(JSON.stringify(snapshot));
      return;
    }

    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ ok: false, error: "Not found" }));
  });

  server.listen(port, "0.0.0.0", () => {
    logger.info({ port }, "health server listening");
  });

  return () => {
    server.close();
  };
}



================================================
FILE: packages/owpenbot/src/logger.ts
================================================
import fs from "node:fs";
import path from "node:path";

import pino from "pino";

export function createLogger(level: string, options?: { logFile?: string }) {
  if (options?.logFile) {
    fs.mkdirSync(path.dirname(options.logFile), { recursive: true });
    const destination = pino.destination({ dest: options.logFile, sync: false });
    return pino({ level, base: undefined }, destination);
  }

  return pino({
    level,
    base: undefined,
  });
}



================================================
FILE: packages/owpenbot/src/opencode.ts
================================================
import { Buffer } from "node:buffer";

import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";

import type { Config } from "./config.js";

type Client = ReturnType<typeof createOpencodeClient>;

export function createClient(config: Config): Client {
  const headers: Record<string, string> = {};
  if (config.opencodeUsername && config.opencodePassword) {
    const token = Buffer.from(`${config.opencodeUsername}:${config.opencodePassword}`).toString("base64");
    headers.Authorization = `Basic ${token}`;
  }

  return createOpencodeClient({
    baseUrl: config.opencodeUrl,
    directory: config.opencodeDirectory,
    headers: Object.keys(headers).length ? headers : undefined,
    responseStyle: "data",
    throwOnError: true,
  });
}

export function buildPermissionRules(mode: Config["permissionMode"]) {
  if (mode === "deny") {
    return [
      {
        permission: "*",
        pattern: "*",
        action: "deny" as const,
      },
    ];
  }

  return [
    {
      permission: "*",
      pattern: "*",
      action: "allow" as const,
    },
  ];
}



================================================
FILE: packages/owpenbot/src/telegram.ts
================================================
import { Bot, type BotError, type Context } from "grammy";
import type { Logger } from "pino";

import type { Config } from "./config.js";

export type InboundMessage = {
  channel: "telegram";
  peerId: string;
  text: string;
  raw: unknown;
};

export type MessageHandler = (message: InboundMessage) => Promise<void> | void;

export type TelegramAdapter = {
  name: "telegram";
  maxTextLength: number;
  start(): Promise<void>;
  stop(): Promise<void>;
  sendText(peerId: string, text: string): Promise<void>;
};

const MAX_TEXT_LENGTH = 4096;

export function createTelegramAdapter(
  config: Config,
  logger: Logger,
  onMessage: MessageHandler,
): TelegramAdapter {
  if (!config.telegramToken) {
    throw new Error("TELEGRAM_BOT_TOKEN is required for Telegram adapter");
  }

  logger.debug({ tokenPresent: true }, "telegram adapter init");
  const bot = new Bot(config.telegramToken);

  bot.catch((err: BotError<Context>) => {
    logger.error({ error: err.error }, "telegram bot error");
  });

  bot.on("message", async (ctx: Context) => {
    const msg = ctx.message;
    if (!msg?.chat) return;

    const chatType = msg.chat.type as string;
    const isGroup = chatType === "group" || chatType === "supergroup" || chatType === "channel";
    if (isGroup && !config.groupsEnabled) {
      logger.debug({ chatId: msg.chat.id, chatType }, "telegram message ignored (group disabled)");
      return;
    }

    const text = msg.text ?? msg.caption ?? "";
    if (!text.trim()) return;

    logger.debug(
      { chatId: msg.chat.id, chatType, length: text.length, preview: text.slice(0, 120) },
      "telegram message received",
    );

    try {
      await onMessage({
        channel: "telegram",
        peerId: String(msg.chat.id),
        text,
        raw: msg,
      });
    } catch (error) {
      logger.error({ error, peerId: msg.chat.id }, "telegram inbound handler failed");
    }
  });

  return {
    name: "telegram",
    maxTextLength: MAX_TEXT_LENGTH,
    async start() {
      logger.debug("telegram adapter starting");
      await bot.start();
      logger.info("telegram adapter started");
    },
    async stop() {
      bot.stop();
      logger.info("telegram adapter stopped");
    },
    async sendText(peerId: string, text: string) {
      await bot.api.sendMessage(Number(peerId), text);
    },
  };
}



================================================
FILE: packages/owpenbot/src/text.ts
================================================
export function chunkText(input: string, limit: number): string[] {
  if (input.length <= limit) return [input];
  const chunks: string[] = [];
  let current = "";

  for (const line of input.split(/\n/)) {
    if ((current + line).length + 1 > limit) {
      if (current) chunks.push(current.trimEnd());
      current = "";
    }
    if (line.length > limit) {
      for (let i = 0; i < line.length; i += limit) {
        const slice = line.slice(i, i + limit);
        if (slice.length) chunks.push(slice);
      }
      continue;
    }
    current += current ? `\n${line}` : line;
  }

  if (current.trim().length) chunks.push(current.trimEnd());
  return chunks.length ? chunks : [input];
}

export function truncateText(text: string, limit: number): string {
  if (text.length <= limit) return text;
  return `${text.slice(0, Math.max(0, limit - 1))}…`;
}

export function formatInputSummary(input: Record<string, unknown>): string {
  const entries = Object.entries(input);
  if (!entries.length) return "";
  try {
    return JSON.stringify(input);
  } catch {
    return entries.map(([key, value]) => `${key}=${String(value)}`).join(", ");
  }
}



================================================
FILE: packages/owpenbot/src/whatsapp-session.ts
================================================
import fs from "node:fs";
import path from "node:path";

import {
  fetchLatestBaileysVersion,
  makeCacheableSignalKeyStore,
  makeWASocket,
  useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import type { Logger } from "pino";

const CREDS_FILE = "creds.json";
const CREDS_BACKUP = "creds.json.bak";
const DEFAULT_TIMEOUT_MS = 120_000;

export type WhatsAppSocket = ReturnType<typeof makeWASocket>;

let credsSaveQueue: Promise<void> = Promise.resolve();

function readCredsRaw(filePath: string): string | null {
  try {
    if (!fs.existsSync(filePath)) return null;
    const stats = fs.statSync(filePath);
    if (!stats.isFile() || stats.size <= 1) return null;
    return fs.readFileSync(filePath, "utf-8");
  } catch {
    return null;
  }
}

function isValidJson(raw: string): boolean {
  try {
    JSON.parse(raw);
    return true;
  } catch {
    return false;
  }
}

function ensureDir(dir: string) {
  fs.mkdirSync(dir, { recursive: true });
}

function backupCreds(authDir: string, logger: Logger) {
  try {
    const credsPath = path.join(authDir, CREDS_FILE);
    const backupPath = path.join(authDir, CREDS_BACKUP);
    const raw = readCredsRaw(credsPath);
    if (!raw || !isValidJson(raw)) return;
    fs.copyFileSync(credsPath, backupPath);
  } catch (error) {
    logger.warn({ error }, "whatsapp creds backup failed");
  }
}

function maybeRestoreCreds(authDir: string, logger: Logger) {
  try {
    const credsPath = path.join(authDir, CREDS_FILE);
    const backupPath = path.join(authDir, CREDS_BACKUP);
    const raw = readCredsRaw(credsPath);
    if (raw && isValidJson(raw)) return;
    const backupRaw = readCredsRaw(backupPath);
    if (!backupRaw || !isValidJson(backupRaw)) return;
    fs.copyFileSync(backupPath, credsPath);
    logger.warn({ credsPath }, "restored whatsapp creds from backup");
  } catch (error) {
    logger.warn({ error }, "whatsapp creds restore failed");
  }
}

function enqueueSaveCreds(authDir: string, saveCreds: () => Promise<void> | void, logger: Logger) {
  credsSaveQueue = credsSaveQueue
    .then(async () => {
      backupCreds(authDir, logger);
      await Promise.resolve(saveCreds());
    })
    .catch((error) => {
      logger.warn({ error }, "whatsapp creds save failed");
    });
}

export function hasWhatsAppCreds(authDir: string): boolean {
  const raw = readCredsRaw(path.join(authDir, CREDS_FILE));
  if (!raw) return false;
  return isValidJson(raw);
}

export function getStatusCode(error: unknown): number | undefined {
  return (
    (error as { output?: { statusCode?: number } })?.output?.statusCode ??
    (error as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
    (error as { status?: number })?.status
  );
}

export async function createWhatsAppSocket(options: {
  authDir: string;
  logger: Logger;
  printQr?: boolean;
  onStatus?: (message: string) => void;
  onQr?: (qr: string) => void;
}): Promise<WhatsAppSocket> {
  const { authDir, logger, printQr, onStatus, onQr } = options;
  ensureDir(authDir);
  maybeRestoreCreds(authDir, logger);
  const { state, saveCreds } = await useMultiFileAuthState(authDir);
  const { version } = await fetchLatestBaileysVersion();

  const sock = makeWASocket({
    auth: {
      creds: state.creds,
      keys: makeCacheableSignalKeyStore(state.keys, logger),
    },
    version,
    logger,
    printQRInTerminal: false,
    syncFullHistory: false,
    markOnlineOnConnect: false,
    browser: ["owpenbot", "cli", "0.1.0"],
  });

  sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, logger));
  sock.ev.on(
    "connection.update",
    (update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
      if (update.qr) {
        onQr?.(update.qr);
        if (printQr) {
          qrcode.generate(update.qr, { small: true });
          onStatus?.("Scan the QR code to connect WhatsApp.");
        }
      }
      if (update.connection === "open") {
        onStatus?.("WhatsApp connected.");
      }
    },
  );

  sock.ws?.on?.("error", (error: Error) => {
    logger.error({ error }, "whatsapp websocket error");
  });

  return sock;
}

export function closeWhatsAppSocket(sock: WhatsAppSocket) {
  try {
    sock.ws?.close();
  } catch {
    // ignore
  }
  try {
    sock.end?.(undefined);
  } catch {
    // ignore
  }
}

export function waitForWhatsAppConnection(
  sock: WhatsAppSocket,
  options: { timeoutMs?: number } = {},
): Promise<void> {
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  return new Promise((resolve, reject) => {
    const ev = sock.ev as unknown as {
      off?: (event: string, listener: (...args: unknown[]) => void) => void;
      removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
    };

    let timer: NodeJS.Timeout | null = null;
    const cleanup = () => {
      ev.off?.("connection.update", handler);
      ev.removeListener?.("connection.update", handler);
      if (timer) clearTimeout(timer);
    };

    const handler = (...args: unknown[]) => {
      const update = (args[0] ?? {}) as Partial<import("@whiskeysockets/baileys").ConnectionState>;
      if (update.connection === "open") {
        cleanup();
        resolve();
        return;
      }
      if (update.connection === "close") {
        cleanup();
        reject(update.lastDisconnect ?? new Error("Connection closed"));
      }
    };

    if (timeoutMs > 0) {
      timer = setTimeout(() => {
        cleanup();
        reject(new Error("Timed out waiting for WhatsApp connection"));
      }, timeoutMs);
    }

    sock.ev.on("connection.update", handler);
  });
}



================================================
FILE: packages/owpenbot/src/whatsapp.ts
================================================
import fs from "node:fs";
import path from "node:path";

import { DisconnectReason, isJidGroup, type WAMessage } from "@whiskeysockets/baileys";
import type { Logger } from "pino";

import type { Config } from "./config.js";
import {
  closeWhatsAppSocket,
  createWhatsAppSocket,
  getStatusCode,
  hasWhatsAppCreds,
  waitForWhatsAppConnection,
  type WhatsAppSocket,
} from "./whatsapp-session.js";

export type InboundMessage = {
  channel: "whatsapp";
  peerId: string;
  text: string;
  raw: unknown;
  fromMe?: boolean;
};

export type MessageHandler = (message: InboundMessage) => Promise<void> | void;

export type WhatsAppAdapter = {
  name: "whatsapp";
  maxTextLength: number;
  start(): Promise<void>;
  stop(): Promise<void>;
  sendText(peerId: string, text: string): Promise<void>;
  sendTyping(peerId: string): Promise<void>;
};

const MAX_TEXT_LENGTH = 3800;
const SENT_MESSAGE_TTL_MS = 10 * 60_000;

function extractText(message: WAMessage): string {
  const content = message.message;
  if (!content) return "";
  return (
    content.conversation ||
    content.extendedTextMessage?.text ||
    content.imageMessage?.caption ||
    content.videoMessage?.caption ||
    content.documentMessage?.caption ||
    ""
  );
}

export function createWhatsAppAdapter(
  config: Config,
  logger: Logger,
  onMessage: MessageHandler,
  opts: { printQr?: boolean; onStatus?: (message: string) => void } = {},
): WhatsAppAdapter {
  let socket: WhatsAppSocket | null = null;
  let stopped = false;
  let connecting = false;
  let reconnectAttempts = 0;
  let reconnectTimer: NodeJS.Timeout | null = null;
  const sentMessageIds = new Map<string, number>();

  const log = logger.child({ channel: "whatsapp" });
  const authDir = path.resolve(config.whatsappAuthDir);

  const reconnectPolicy = {
    initialMs: 1500,
    maxMs: 30_000,
    factor: 1.6,
    jitter: 0.25,
    maxAttempts: 10,
  };

  const computeDelay = (attempt: number) => {
    const base = reconnectPolicy.initialMs * Math.pow(reconnectPolicy.factor, Math.max(0, attempt - 1));
    const capped = Math.min(base, reconnectPolicy.maxMs);
    const jitter = capped * reconnectPolicy.jitter * (Math.random() * 2 - 1);
    return Math.max(250, Math.round(capped + jitter));
  };

  const resetReconnect = () => {
    reconnectAttempts = 0;
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
  };

  const scheduleReconnect = (statusCode?: number) => {
    if (stopped || reconnectTimer) return;
    reconnectAttempts += 1;
    if (reconnectAttempts > reconnectPolicy.maxAttempts) {
      log.warn({ attempts: reconnectAttempts }, "whatsapp reconnect attempts exhausted");
      opts.onStatus?.("WhatsApp reconnect attempts exhausted. Run: owpenwork whatsapp login.");
      return;
    }
    const delayMs = statusCode === 515 ? 1000 : computeDelay(reconnectAttempts);
    log.warn({ delayMs, statusCode }, "whatsapp reconnect scheduled");
    opts.onStatus?.(`WhatsApp reconnecting in ${Math.round(delayMs / 1000)}s...`);
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null;
      void connect({ printQr: false });
    }, delayMs);
  };

  const recordSentMessage = (messageId?: string | null) => {
    if (!messageId) return;
    sentMessageIds.set(messageId, Date.now());
  };

  const pruneSentMessages = () => {
    const now = Date.now();
    for (const [id, timestamp] of sentMessageIds) {
      if (now - timestamp > SENT_MESSAGE_TTL_MS) {
        sentMessageIds.delete(id);
      }
    }
  };

  async function connect(options: { printQr?: boolean } = {}) {
    if (stopped || connecting) return;
    connecting = true;
    try {
      if (socket) {
        closeWhatsAppSocket(socket);
        socket = null;
      }
      const sock = await createWhatsAppSocket({
        authDir,
        logger: log,
        printQr: options.printQr ?? opts.printQr,
        onStatus: opts.onStatus,
      });

      sock.ev.on(
        "connection.update",
        (update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
          if (update.connection === "open") {
            resetReconnect();
          }
          if (update.connection === "close") {
            const statusCode = getStatusCode(
              (update.lastDisconnect as { error?: unknown } | undefined)?.error ?? update.lastDisconnect,
            );
            if (statusCode === DisconnectReason.loggedOut) {
              log.warn("whatsapp logged out, run 'owpenbot whatsapp login'");
              opts.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
              return;
            }
            if (statusCode === 515) {
              opts.onStatus?.("WhatsApp asked for a restart; reconnecting.");
            }
            scheduleReconnect(statusCode);
          }
        },
      );

      sock.ev.on("messages.upsert", async ({ messages }: { messages: WAMessage[] }) => {
        pruneSentMessages();
        for (const msg of messages) {
          if (!msg.message) continue;
          const fromMe = Boolean(msg.key.fromMe);
          const messageId = msg.key.id;
          if (fromMe && messageId && sentMessageIds.has(messageId)) {
            sentMessageIds.delete(messageId);
            continue;
          }
          if (fromMe && !config.whatsappSelfChatMode) continue;
          const peerId = msg.key.remoteJid;
          if (!peerId) continue;
          if (isJidGroup(peerId) && !config.groupsEnabled) {
            continue;
          }
          const text = extractText(msg);
          if (!text.trim()) continue;

          try {
            await onMessage({
              channel: "whatsapp",
              peerId,
              text,
              raw: msg,
              fromMe,
            });
          } catch (error) {
            log.error({ error, peerId }, "whatsapp inbound handler failed");
          }
        }
      });

      socket = sock;
    } finally {
      connecting = false;
    }
  }

  return {
    name: "whatsapp",
    maxTextLength: MAX_TEXT_LENGTH,
    async start() {
      await connect();
    },
    async stop() {
      stopped = true;
      if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
      }
      if (socket) {
        closeWhatsAppSocket(socket);
        socket = null;
      }
    },
    async sendText(peerId: string, text: string) {
      if (!socket) throw new Error("WhatsApp socket not initialized");
      const sent = await socket.sendMessage(peerId, { text });
      recordSentMessage(sent?.key?.id);
    },
    async sendTyping(peerId: string) {
      if (!socket) return;
      try {
        await socket.sendPresenceUpdate("composing", peerId);
      } catch (error) {
        log.warn({ error, peerId }, "whatsapp typing update failed");
      }
    },
  };
}

export async function loginWhatsApp(
  config: Config,
  logger: Logger,
  options: { onStatus?: (message: string) => void; timeoutMs?: number } = {},
) {
  const authDir = path.resolve(config.whatsappAuthDir);
  const log = logger.child({ channel: "whatsapp" });
  const timeoutMs = options.timeoutMs ?? 120_000;

  const attemptLogin = async (phase: "initial" | "restart", printQr: boolean) => {
    const sock = await createWhatsAppSocket({
      authDir,
      logger: log,
      printQr,
      onStatus: options.onStatus,
    });
    try {
      if (phase === "initial") {
        options.onStatus?.("Waiting for WhatsApp scan...");
      } else {
        options.onStatus?.("Reconnecting WhatsApp session...");
      }
      await waitForWhatsAppConnection(sock, { timeoutMs });
      options.onStatus?.("WhatsApp linked.");
      return { ok: true } as const;
    } catch (error) {
      const statusCode = getStatusCode(error);
      return { ok: false, error, statusCode } as const;
    } finally {
      setTimeout(() => closeWhatsAppSocket(sock), 500);
    }
  };

  const initial = await attemptLogin("initial", true);
  if (initial.ok) return;

  if (initial.statusCode === DisconnectReason.loggedOut) {
    options.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
    throw new Error("WhatsApp logged out");
  }

  const shouldRetry =
    initial.statusCode === 515 || (initial.statusCode === undefined && hasWhatsAppCreds(authDir));

  if (shouldRetry) {
    options.onStatus?.("WhatsApp asked for a restart; retrying connection...");
    const retry = await attemptLogin("restart", false);
    if (retry.ok) return;
    if (retry.statusCode === DisconnectReason.loggedOut) {
      options.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
    }
    throw new Error(`WhatsApp login failed after restart: ${String(retry.error)}`);
  }

  if (!initial.statusCode && !hasWhatsAppCreds(authDir)) {
    options.onStatus?.("Timed out waiting for QR scan. Run login again for a fresh QR.");
  }

  throw new Error(`WhatsApp login failed: ${String(initial.error)}`);
}

export function unpairWhatsApp(config: Config, logger: Logger) {
  const authDir = path.resolve(config.whatsappAuthDir);
  if (!fs.existsSync(authDir)) {
    logger.info({ authDir }, "whatsapp auth directory not found");
    return;
  }
  fs.rmSync(authDir, { recursive: true, force: true });
  logger.info({ authDir }, "whatsapp auth cleared; run owpenbot to re-pair");
}



================================================
FILE: packages/owpenbot/test/db.test.js
================================================
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { BridgeStore } from "../dist/db.js";

test("BridgeStore allowlist and sessions", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-"));
  const dbPath = path.join(dir, "owpenbot.db");
  const store = new BridgeStore(dbPath);

  assert.equal(store.isAllowed("telegram", "123"), false);
  store.allowPeer("telegram", "123");
  assert.equal(store.isAllowed("telegram", "123"), true);

  store.upsertSession("telegram", "123", "session-1");
  const row = store.getSession("telegram", "123");
  assert.equal(row?.session_id, "session-1");

  store.close();
});

test("BridgeStore pairing requests", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-"));
  const dbPath = path.join(dir, "owpenbot.db");
  const store = new BridgeStore(dbPath);

  store.createPairingRequest("whatsapp", "+15551234567", "123456", 1000);
  const list = store.listPairingRequests("whatsapp");
  assert.equal(list.length, 1);
  assert.equal(list[0].code, "123456");

  const approved = store.approvePairingRequest("whatsapp", "123456");
  assert.equal(approved?.peer_id, "+15551234567");

  const empty = store.listPairingRequests("whatsapp");
  assert.equal(empty.length, 0);

  store.close();
});



================================================
FILE: packages/server/README.md
================================================
# OpenWork Server

Filesystem-backed API for OpenWork remote clients. This package provides the OpenWork server layer described in `packages/app/pr/openwork-server.md` and is intentionally independent from the desktop app.

## Quick start

```bash
pnpm --filter @different-ai/openwork-server dev -- \
  --workspace /path/to/workspace \
  --approval auto
```

The server logs the client token and host token on boot when they are auto-generated.

## Config file

Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CONFIG` or `--config`).

```json
{
  "host": "127.0.0.1",
  "port": 8787,
  "approval": { "mode": "manual", "timeoutMs": 30000 },
  "workspaces": [
    { "path": "/Users/susan/Finance", "name": "Finance", "workspaceType": "local" }
  ],
  "corsOrigins": ["http://localhost:5173"]
}
```

## Environment variables

- `OPENWORK_SERVER_CONFIG` path to config JSON
- `OPENWORK_HOST` / `OPENWORK_PORT`
- `OPENWORK_TOKEN` client bearer token
- `OPENWORK_HOST_TOKEN` host approval token
- `OPENWORK_APPROVAL_MODE` (`manual` | `auto`)
- `OPENWORK_APPROVAL_TIMEOUT_MS`
- `OPENWORK_WORKSPACES` (JSON array or comma-separated list of paths)
- `OPENWORK_CORS_ORIGINS` (comma-separated list or `*`)

## Endpoints (initial)

- `GET /health`
- `GET /capabilities`
- `GET /workspaces`
- `GET /workspace/:id/config`
- `PATCH /workspace/:id/config`
- `GET /workspace/:id/plugins`
- `POST /workspace/:id/plugins`
- `DELETE /workspace/:id/plugins/:name`
- `GET /workspace/:id/skills`
- `POST /workspace/:id/skills`
- `GET /workspace/:id/mcp`
- `POST /workspace/:id/mcp`
- `DELETE /workspace/:id/mcp/:name`
- `GET /workspace/:id/commands`
- `POST /workspace/:id/commands`
- `DELETE /workspace/:id/commands/:name`
- `GET /workspace/:id/audit`
- `GET /workspace/:id/export`
- `POST /workspace/:id/import`

## Approvals

All writes are gated by host approval. Host APIs require `X-OpenWork-Host-Token`:

- `GET /approvals`
- `POST /approvals/:id` with `{ "reply": "allow" | "deny" }`

Set `OPENWORK_APPROVAL_MODE=auto` to auto-approve during local development.



================================================
FILE: packages/server/package.json
================================================
{
  "name": "@different-ai/openwork-server",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "bin": {
    "openwork-server": "dist/cli.js"
  },
  "scripts": {
    "dev": "bun src/cli.ts",
    "build": "tsc -p tsconfig.json",
    "build:bin": "bun ./script/build.ts --outdir dist/bin",
    "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-windows-x64",
    "start": "bun dist/cli.js",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": {
    "jsonc-parser": "^3.2.1",
    "minimatch": "^10.0.1",
    "yaml": "^2.6.1"
  },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "@types/minimatch": "^5.1.2",
    "bun-types": "^1.3.6",
    "typescript": "^5.6.3"
  },
  "packageManager": "pnpm@10.27.0"
}



================================================
FILE: packages/server/tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "types": ["bun-types", "node"]
  },
  "include": ["src"]
}



================================================
FILE: packages/server/script/build.ts
================================================
import { spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join, resolve } from "node:path";

const bunRuntime = (globalThis as typeof globalThis & {
  Bun?: {
    build?: (...args: any[]) => Promise<any>;
    argv?: string[];
  };
}).Bun;

if (!bunRuntime?.build || !bunRuntime.argv) {
  console.error("This script must be run with Bun.");
  process.exit(1);
}

const bun = bunRuntime as { build: (...args: any[]) => Promise<any>; argv: string[] };

type BuildOptions = {
  targets: string[];
  outdir: string;
  filename: string;
};

function readArgs(argv: string[]): BuildOptions {
  const options: BuildOptions = {
    targets: [],
    outdir: resolve("dist", "bin"),
    filename: "openwork-server",
  };

  for (let index = 0; index < argv.length; index += 1) {
    const value = argv[index];
    if (!value) continue;

    if (value === "--target") {
      const next = argv[index + 1];
      if (next) {
        options.targets.push(next);
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--target=")) {
      const next = value.slice("--target=".length).trim();
      if (next) options.targets.push(next);
      continue;
    }

    if (value === "--outdir") {
      const next = argv[index + 1];
      if (next) {
        options.outdir = resolve(next);
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--outdir=")) {
      const next = value.slice("--outdir=".length).trim();
      if (next) options.outdir = resolve(next);
      continue;
    }

    if (value === "--filename") {
      const next = argv[index + 1];
      if (next) {
        options.filename = next;
        index += 1;
      }
      continue;
    }

    if (value.startsWith("--filename=")) {
      const next = value.slice("--filename=".length).trim();
      if (next) options.filename = next;
    }
  }

  return options;
}

function outputName(filename: string, target?: string) {
  const needsExe = target ? target.includes("windows") : process.platform === "win32";
  const suffix = target ? `-${target}` : "";
  const ext = needsExe ? ".exe" : "";
  return `${filename}${suffix}${ext}`;
}

async function buildOnce(entrypoint: string, outdir: string, filename: string, target?: string) {
  mkdirSync(outdir, { recursive: true });
  const outfile = join(outdir, outputName(filename, target));

  const args = ["build", entrypoint, "--compile", "--outfile", outfile];
  if (target) {
    args.push("--target", target);
  }

  const result = spawnSync("bun", args, { stdio: "inherit" });
  if (result.status !== 0) {
    process.exit(result.status ?? 1);
  }
}

const options = readArgs(bun.argv.slice(2));
const entrypoint = resolve("src", "cli.ts");
const targets = options.targets.length ? options.targets : [undefined];

for (const target of targets) {
  await buildOnce(entrypoint, options.outdir, options.filename, target);
}



================================================
FILE: packages/server/src/approvals.ts
================================================
import type { ApprovalConfig, ApprovalRequest } from "./types.js";
import { shortId } from "./utils.js";

interface ApprovalResult {
  id: string;
  allowed: boolean;
  reason?: string;
}

interface PendingApproval {
  request: ApprovalRequest;
  resolve: (result: ApprovalResult) => void;
  timeout?: NodeJS.Timeout;
}

export class ApprovalService {
  private config: ApprovalConfig;
  private pending = new Map<string, PendingApproval>();

  constructor(config: ApprovalConfig) {
    this.config = config;
  }

  list(): ApprovalRequest[] {
    return Array.from(this.pending.values()).map((entry) => entry.request);
  }

  async requestApproval(
    input: Omit<ApprovalRequest, "id" | "createdAt">,
  ): Promise<ApprovalResult> {
    if (this.config.mode === "auto") {
      return { id: "auto", allowed: true };
    }
    const id = shortId();
    const request: ApprovalRequest = {
      ...input,
      id,
      createdAt: Date.now(),
    };

    const result = await new Promise<ApprovalResult>((resolve) => {
      const timeout = setTimeout(() => {
        this.pending.delete(id);
        resolve({ id, allowed: false, reason: "timeout" });
      }, this.config.timeoutMs);

      this.pending.set(id, { request, resolve, timeout });
    });

    return result;
  }

  respond(id: string, reply: "allow" | "deny"): ApprovalResult | null {
    const pending = this.pending.get(id);
    if (!pending) return null;
    if (pending.timeout) clearTimeout(pending.timeout);
    this.pending.delete(id);
    const result: ApprovalResult = {
      id,
      allowed: reply === "allow",
      reason: reply === "allow" ? undefined : "denied",
    };
    pending.resolve(result);
    return result;
  }
}



================================================
FILE: packages/server/src/audit.ts
================================================
import { dirname, join } from "node:path";
import { appendFile, readFile } from "node:fs/promises";
import type { AuditEntry } from "./types.js";
import { ensureDir, exists } from "./utils.js";

export function auditLogPath(workspaceRoot: string): string {
  return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
}

export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise<void> {
  const path = auditLogPath(workspaceRoot);
  await ensureDir(dirname(path));
  await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
}

export async function readLastAudit(workspaceRoot: string): Promise<AuditEntry | null> {
  const path = auditLogPath(workspaceRoot);
  if (!(await exists(path))) return null;
  const content = await readFile(path, "utf8");
  const lines = content.trim().split("\n");
  const last = lines[lines.length - 1];
  if (!last) return null;
  try {
    return JSON.parse(last) as AuditEntry;
  } catch {
    return null;
  }
}

export async function readAuditEntries(workspaceRoot: string, limit = 50): Promise<AuditEntry[]> {
  const path = auditLogPath(workspaceRoot);
  if (!(await exists(path))) return [];
  const content = await readFile(path, "utf8");
  const rawLines = content.trim().split("\n").filter(Boolean);
  if (!rawLines.length) return [];
  const slice = rawLines.slice(-Math.max(1, limit));
  const entries: AuditEntry[] = [];
  for (let i = slice.length - 1; i >= 0; i -= 1) {
    try {
      entries.push(JSON.parse(slice[i]) as AuditEntry);
    } catch {
      // ignore malformed entry
    }
  }
  return entries;
}



================================================
FILE: packages/server/src/bun.d.ts
================================================
declare const Bun: {
  serve: (options: {
    hostname: string;
    port: number;
    fetch: (request: Request) => Response | Promise<Response>;
  }) => {
    port: number;
  };
};



================================================
FILE: packages/server/src/cli.ts
================================================
#!/usr/bin/env bun

import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
import { startServer } from "./server.js";

const args = parseCliArgs(process.argv.slice(2));

if (args.help) {
  printHelp();
  process.exit(0);
}

const config = await resolveServerConfig(args);
const server = startServer(config);

const url = `http://${config.host}:${server.port}`;
console.log(`OpenWork server listening on ${url}`);

if (config.tokenSource === "generated") {
  console.log(`Client token: ${config.token}`);
}

if (config.hostTokenSource === "generated") {
  console.log(`Host token: ${config.hostToken}`);
}

if (config.workspaces.length === 0) {
  console.log("No workspaces configured. Add --workspace or update server.json.");
} else {
  console.log(`Workspaces: ${config.workspaces.length}`);
}



================================================
FILE: packages/server/src/commands.ts
================================================
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
import type { CommandItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { projectCommandsDir } from "./workspace-files.js";
import { validateCommandName, sanitizeCommandName } from "./validators.js";
import { ApiError } from "./errors.js";

async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
  if (!(await exists(dir))) return [];
  const entries = await readdir(dir, { withFileTypes: true });
  const items: CommandItem[] = [];
  for (const entry of entries) {
    if (!entry.isFile()) continue;
    if (!entry.name.endsWith(".md")) continue;
    const filePath = join(dir, entry.name);
    const content = await readFile(filePath, "utf8");
    const { data, body } = parseFrontmatter(content);
    const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
    try {
      validateCommandName(name);
    } catch {
      continue;
    }
    items.push({
      name,
      description: typeof data.description === "string" ? data.description : undefined,
      template: body.trim(),
      agent: typeof data.agent === "string" ? data.agent : undefined,
      model: typeof data.model === "string" ? data.model : null,
      subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
      scope,
    });
  }
  return items;
}

export async function listCommands(workspaceRoot: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
  if (scope === "global") {
    const dir = join(homedir(), ".config", "opencode", "commands");
    return listCommandsInDir(dir, "global");
  }
  return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
}

export async function upsertCommand(
  workspaceRoot: string,
  payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
): Promise<string> {
  if (!payload.template || payload.template.trim().length === 0) {
    throw new ApiError(400, "invalid_command_template", "Command template is required");
  }
  const sanitized = sanitizeCommandName(payload.name);
  validateCommandName(sanitized);
  const frontmatter = buildFrontmatter({
    name: sanitized,
    description: payload.description,
    agent: payload.agent,
    model: payload.model ?? null,
    subtask: payload.subtask ?? false,
  });
  const content = frontmatter + "\n" + payload.template.trim() + "\n";
  const dir = projectCommandsDir(workspaceRoot);
  await mkdir(dir, { recursive: true });
  const path = join(dir, `${sanitized}.md`);
  await writeFile(path, content, "utf8");
  return path;
}

export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
  const sanitized = sanitizeCommandName(name);
  validateCommandName(sanitized);
  const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
  await rm(path, { force: true });
}



================================================
FILE: packages/server/src/config.ts
================================================
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import type { ApprovalMode, ApprovalConfig, ServerConfig, WorkspaceConfig } from "./types.js";
import { buildWorkspaceInfos } from "./workspaces.js";
import { parseList, readJsonFile, shortId } from "./utils.js";

interface CliArgs {
  configPath?: string;
  host?: string;
  port?: number;
  token?: string;
  hostToken?: string;
  approvalMode?: ApprovalMode;
  approvalTimeoutMs?: number;
  opencodeBaseUrl?: string;
  opencodeDirectory?: string;
  workspaces: string[];
  corsOrigins?: string[];
  readOnly?: boolean;
  help?: boolean;
}

interface FileConfig {
  host?: string;
  port?: number;
  token?: string;
  hostToken?: string;
  approval?: Partial<ApprovalConfig>;
  workspaces?: WorkspaceConfig[];
  corsOrigins?: string[];
  authorizedRoots?: string[];
  readOnly?: boolean;
}

const DEFAULT_PORT = 8787;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_TIMEOUT_MS = 30000;

export function parseCliArgs(argv: string[]): CliArgs {
  const args: CliArgs = { workspaces: [] };
  for (let index = 0; index < argv.length; index += 1) {
    const value = argv[index];
    if (!value) continue;
    if (value === "--help" || value === "-h") {
      args.help = true;
      continue;
    }
    if (value === "--config") {
      args.configPath = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--host") {
      args.host = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--port") {
      const port = Number(argv[index + 1]);
      if (!Number.isNaN(port)) args.port = port;
      index += 1;
      continue;
    }
    if (value === "--token") {
      args.token = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--host-token") {
      args.hostToken = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--approval") {
      const mode = argv[index + 1] as ApprovalMode | undefined;
      if (mode === "manual" || mode === "auto") args.approvalMode = mode;
      index += 1;
      continue;
    }
    if (value === "--approval-timeout") {
      const timeout = Number(argv[index + 1]);
      if (!Number.isNaN(timeout)) args.approvalTimeoutMs = timeout;
      index += 1;
      continue;
    }
    if (value === "--opencode-base-url") {
      args.opencodeBaseUrl = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--opencode-directory") {
      args.opencodeDirectory = argv[index + 1];
      index += 1;
      continue;
    }
    if (value === "--workspace") {
      const path = argv[index + 1];
      if (path) args.workspaces.push(path);
      index += 1;
      continue;
    }
    if (value === "--cors") {
      args.corsOrigins = parseList(argv[index + 1]);
      index += 1;
      continue;
    }
    if (value === "--read-only") {
      args.readOnly = true;
      continue;
    }
  }
  return args;
}

export function printHelp(): void {
  const message = [
    "openwork-server",
    "",
    "Options:",
    "  --config <path>          Path to server.json",
    "  --host <host>            Hostname (default 127.0.0.1)",
    "  --port <port>            Port (default 8787)",
    "  --token <token>          Client bearer token",
    "  --host-token <token>     Host approval token",
    "  --approval <mode>        manual | auto",
    "  --approval-timeout <ms>  Approval timeout",
    "  --opencode-base-url <url> OpenCode base URL to share",
    "  --opencode-directory <path> OpenCode workspace directory to share",
    "  --workspace <path>       Workspace root (repeatable)",
    "  --cors <origins>          Comma-separated origins or *",
    "  --read-only              Disable writes",
  ].join("\n");
  console.log(message);
}

async function loadFileConfig(configPath: string): Promise<FileConfig> {
  const parsed = await readJsonFile<FileConfig>(configPath);
  return parsed ?? {};
}

export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
  const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
  const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
  const fileConfig = await loadFileConfig(configPath);
  const configDir = dirname(configPath);

  const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
  const workspaceConfigs: WorkspaceConfig[] =
    cli.workspaces.length > 0
      ? cli.workspaces.map((path) => ({ path }))
      : envWorkspaces.length > 0
        ? envWorkspaces.map((path) => ({ path }))
        : fileConfig.workspaces ?? [];

  const envOpencodeBaseUrl = process.env.OPENWORK_OPENCODE_BASE_URL;
  const envOpencodeDirectory = process.env.OPENWORK_OPENCODE_DIRECTORY;
  const opencodeBaseUrl = cli.opencodeBaseUrl ?? envOpencodeBaseUrl;
  const opencodeDirectory = cli.opencodeDirectory ?? envOpencodeDirectory;

  if (workspaceConfigs.length > 0 && (opencodeBaseUrl || opencodeDirectory)) {
    workspaceConfigs[0] = {
      ...workspaceConfigs[0],
      baseUrl: opencodeBaseUrl ?? workspaceConfigs[0].baseUrl,
      directory: opencodeDirectory ?? workspaceConfigs[0].directory,
    };
  }

  const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);

  const tokenFromEnv = process.env.OPENWORK_TOKEN;
  const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;

  const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
  const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();

  const tokenSource: ServerConfig["tokenSource"] = cli.token
    ? "cli"
    : tokenFromEnv
      ? "env"
      : fileConfig.token
        ? "file"
        : "generated";

  const hostTokenSource: ServerConfig["hostTokenSource"] = cli.hostToken
    ? "cli"
    : hostTokenFromEnv
      ? "env"
      : fileConfig.hostToken
        ? "file"
        : "generated";

  const approvalMode =
    cli.approvalMode ??
    (process.env.OPENWORK_APPROVAL_MODE as ApprovalMode | undefined) ??
    fileConfig.approval?.mode ??
    "manual";

  const approvalTimeoutMs =
    cli.approvalTimeoutMs ??
    (process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
    fileConfig.approval?.timeoutMs ??
    DEFAULT_TIMEOUT_MS;

  const approval: ApprovalConfig = {
    mode: approvalMode === "auto" ? "auto" : "manual",
    timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
  };

  const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
  const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
  const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];

  const envReadOnly = process.env.OPENWORK_READONLY;
  const parsedReadOnly = envReadOnly
    ? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
    : undefined;
  const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;

  const authorizedRoots =
    fileConfig.authorizedRoots?.length
      ? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
      : workspaces.map((workspace) => workspace.path);

  const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
  const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;

  return {
    host,
    port: Number.isNaN(port) ? DEFAULT_PORT : port,
    token,
    hostToken,
    approval,
    corsOrigins,
    workspaces,
    authorizedRoots,
    readOnly,
    startedAt: Date.now(),
    tokenSource,
    hostTokenSource,
  };
}



================================================
FILE: packages/server/src/errors.ts
================================================
import type { ApiErrorBody } from "./types.js";

export class ApiError extends Error {
  status: number;
  code: string;
  details?: unknown;

  constructor(status: number, code: string, message: string, details?: unknown) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

export function formatError(err: ApiError): ApiErrorBody {
  return {
    code: err.code,
    message: err.message,
    details: err.details,
  };
}



================================================
FILE: packages/server/src/frontmatter.ts
================================================
import { parse, stringify } from "yaml";

export function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  if (!match) {
    return { data: {}, body: content };
  }
  const raw = match[1] ?? "";
  const data = (parse(raw) as Record<string, unknown>) ?? {};
  const body = content.slice(match[0].length);
  return { data, body };
}

export function buildFrontmatter(data: Record<string, unknown>): string {
  const yaml = stringify(data).trimEnd();
  return `---\n${yaml}\n---\n`;
}



================================================
FILE: packages/server/src/jsonc.ts
================================================
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { dirname } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { ApiError } from "./errors.js";
import { ensureDir, exists } from "./utils.js";

interface ParseResult<T> {
  data: T;
  raw: string;
}

export async function readJsoncFile<T>(path: string, fallback: T): Promise<ParseResult<T>> {
  if (!(await exists(path))) {
    return { data: fallback, raw: "" };
  }
  const raw = await readFile(path, "utf8");
  const errors: { error: number; offset: number; length: number }[] = [];
  const data = parse(raw, errors, { allowTrailingComma: true }) as T;
  if (errors.length > 0) {
    const details = errors.map((error) => ({
      code: printParseErrorCode(error.error),
      offset: error.offset,
      length: error.length,
    }));
    throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
  }
  return { data, raw };
}

export async function updateJsoncTopLevel(path: string, updates: Record<string, unknown>): Promise<void> {
  const hasFile = await exists(path);
  if (!hasFile) {
    await ensureDir(dirname(path));
    const content = JSON.stringify(updates, null, 2) + "\n";
    await writeFile(path, content, "utf8");
    return;
  }

  let content = await readFile(path, "utf8");
  const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
  for (const [key, value] of Object.entries(updates)) {
    const edits = modify(content, [key], value, { formattingOptions });
    content = applyEdits(content, edits);
  }
  await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
}

export async function writeJsoncFile(path: string, value: unknown): Promise<void> {
  await ensureDir(dirname(path));
  const content = JSON.stringify(value, null, 2) + "\n";
  await writeFile(path, content, "utf8");
}



================================================
FILE: packages/server/src/mcp.ts
================================================
import { minimatch } from "minimatch";
import type { McpItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath } from "./workspace-files.js";
import { validateMcpConfig, validateMcpName } from "./validators.js";

function getMcpConfig(config: Record<string, unknown>): Record<string, Record<string, unknown>> {
  const mcp = config.mcp;
  if (!mcp || typeof mcp !== "object") return {};
  return mcp as Record<string, Record<string, unknown>>;
}

function getDeniedToolPatterns(config: Record<string, unknown>): string[] {
  const tools = config.tools;
  if (!tools || typeof tools !== "object") return [];
  const deny = (tools as { deny?: unknown }).deny;
  if (!Array.isArray(deny)) return [];
  return deny.filter((item) => typeof item === "string") as string[];
}

function isMcpDisabledByTools(config: Record<string, unknown>, name: string): boolean {
  const patterns = getDeniedToolPatterns(config);
  if (patterns.length === 0) return false;
  const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
  return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
}

export async function listMcp(workspaceRoot: string): Promise<McpItem[]> {
  const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const mcpMap = getMcpConfig(config);
  return Object.entries(mcpMap).map(([name, entry]) => ({
    name,
    config: entry,
    source: "config.project",
    disabledByTools: isMcpDisabledByTools(config, name) || undefined,
  }));
}

export async function addMcp(workspaceRoot: string, name: string, config: Record<string, unknown>): Promise<void> {
  validateMcpName(name);
  validateMcpConfig(config);
  const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const mcpMap = getMcpConfig(data);
  mcpMap[name] = config;
  await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}

export async function removeMcp(workspaceRoot: string, name: string): Promise<void> {
  const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const mcpMap = getMcpConfig(data);
  delete mcpMap[name];
  await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}



================================================
FILE: packages/server/src/paths.ts
================================================
import { realpath } from "node:fs/promises";
import { isAbsolute, resolve, sep } from "node:path";
import { ApiError } from "./errors.js";

export function assertAbsolute(path: string): void {
  if (!isAbsolute(path)) {
    throw new ApiError(400, "invalid_path", "Path must be absolute");
  }
}

export async function resolveWithinRoot(root: string, ...segments: string[]): Promise<string> {
  const resolvedRoot = await realpath(root);
  const candidate = resolve(resolvedRoot, ...segments);
  const resolvedCandidate = await realpath(candidate).catch(() => candidate);
  if (resolvedCandidate === resolvedRoot) return candidate;
  if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
    throw new ApiError(400, "path_escape", "Path escapes workspace root");
  }
  return candidate;
}



================================================
FILE: packages/server/src/plugins.ts
================================================
import { homedir } from "node:os";
import { join, relative } from "node:path";
import { readdir } from "node:fs/promises";
import type { PluginItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
import { exists } from "./utils.js";
import { validatePluginSpec } from "./validators.js";

function normalizePluginSpec(spec: string): string {
  const trimmed = spec.trim();
  if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
    return trimmed;
  }
  if (trimmed.startsWith("/")) {
    return trimmed;
  }
  if (trimmed.startsWith("@")) {
    const atIndex = trimmed.indexOf("@", 1);
    return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
  }
  const atIndex = trimmed.indexOf("@");
  return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}

function pluginListFromConfig(config: Record<string, unknown>): string[] {
  const plugin = config.plugin;
  if (typeof plugin === "string") return [plugin];
  if (Array.isArray(plugin)) return plugin.filter((item) => typeof item === "string") as string[];
  return [];
}

async function listPluginFiles(dir: string, scope: "project" | "global", workspaceRoot?: string): Promise<PluginItem[]> {
  if (!(await exists(dir))) return [];
  const entries = await readdir(dir, { withFileTypes: true });
  const items: PluginItem[] = [];
  for (const entry of entries) {
    if (!entry.isFile()) continue;
    if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts")) continue;
    const absolutePath = join(dir, entry.name);
    const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
    items.push({
      spec: `file://${absolutePath}`,
      source: scope === "project" ? "dir.project" : "dir.global",
      scope,
      path: relativePath,
    });
  }
  return items;
}

export async function listPlugins(workspaceRoot: string, includeGlobal: boolean): Promise<{ items: PluginItem[]; loadOrder: string[] }> {
  const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const pluginSpecs = pluginListFromConfig(config);
  const items: PluginItem[] = pluginSpecs.map((spec) => ({
    spec,
    source: "config",
    scope: "project",
  }));

  const projectDir = projectPluginsDir(workspaceRoot);
  items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));

  if (includeGlobal) {
    const globalDir = join(homedir(), ".config", "opencode", "plugins");
    items.push(...(await listPluginFiles(globalDir, "global")));
  }

  return {
    items,
    loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
  };
}

export async function addPlugin(workspaceRoot: string, spec: string): Promise<void> {
  validatePluginSpec(spec);
  const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const pluginSpecs = pluginListFromConfig(config);
  const normalized = normalizePluginSpec(spec);
  const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
  if (existing) return;
  pluginSpecs.push(spec);
  await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
}

export async function removePlugin(workspaceRoot: string, name: string): Promise<void> {
  const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  const pluginSpecs = pluginListFromConfig(config);
  const normalized = normalizePluginSpec(name);
  const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
  await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
}



================================================
FILE: packages/server/src/server.ts
================================================
import { readFile, writeFile, rm } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor } from "./types.js";
import { ApprovalService } from "./approvals.js";
import { addPlugin, listPlugins, removePlugin } from "./plugins.js";
import { addMcp, listMcp, removeMcp } from "./mcp.js";
import { listSkills, upsertSkill } from "./skills.js";
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
import { ApiError, formatError } from "./errors.js";
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js";
import { parseFrontmatter } from "./frontmatter.js";
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
import { sanitizeCommandName } from "./validators.js";

type AuthMode = "none" | "client" | "host";

interface Route {
  method: string;
  regex: RegExp;
  keys: string[];
  auth: AuthMode;
  handler: (ctx: RequestContext) => Promise<Response>;
}

interface RequestContext {
  request: Request;
  url: URL;
  params: Record<string, string>;
  config: ServerConfig;
  approvals: ApprovalService;
  actor?: Actor;
}

export function startServer(config: ServerConfig) {
  const approvals = new ApprovalService(config.approval);
  const routes = createRoutes(config, approvals);

  const server = Bun.serve({
    hostname: config.host,
    port: config.port,
    fetch: async (request: Request) => {
      const url = new URL(request.url);
      if (request.method === "OPTIONS") {
        return withCors(new Response(null, { status: 204 }), request, config);
      }

      const route = matchRoute(routes, request.method, url.pathname);
      if (!route) {
        return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
      }

      try {
        const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
        const response = await route.handler({ request, url, params: route.params, config, approvals, actor });
        return withCors(response, request, config);
      } catch (error) {
        const apiError = error instanceof ApiError
          ? error
          : new ApiError(500, "internal_error", "Unexpected server error");
        return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
      }
    },
  });

  return server;
}

function matchRoute(routes: Route[], method: string, path: string) {
  for (const route of routes) {
    if (route.method !== method) continue;
    const match = path.match(route.regex);
    if (!match) continue;
    const params: Record<string, string> = {};
    route.keys.forEach((key, index) => {
      params[key] = decodeURIComponent(match[index + 1]);
    });
    return { ...route, params };
  }
  return null;
}

function addRoute(routes: Route[], method: string, path: string, auth: AuthMode, handler: Route["handler"]) {
  const keys: string[] = [];
  const regex = pathToRegex(path, keys);
  routes.push({ method, regex, keys, auth, handler });
}

function pathToRegex(path: string, keys: string[]): RegExp {
  const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
    keys.push(key);
    return "([^/]+)";
  });
  return new RegExp(`^${pattern}$`);
}

function jsonResponse(data: unknown, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

function withCors(response: Response, request: Request, config: ServerConfig) {
  const origin = request.headers.get("origin");
  const allowedOrigins = config.corsOrigins;
  let allowOrigin: string | null = null;
  if (allowedOrigins.includes("*")) {
    allowOrigin = "*";
  } else if (origin && allowedOrigins.includes(origin)) {
    allowOrigin = origin;
  }

  if (!allowOrigin) return response;
  const headers = new Headers(response.headers);
  headers.set("Access-Control-Allow-Origin", allowOrigin);
  headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
  headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
  headers.set("Vary", "Origin");
  return new Response(response.body, { status: response.status, headers });
}

function requireClient(request: Request, config: ServerConfig): Actor {
  const header = request.headers.get("authorization") ?? "";
  const match = header.match(/^Bearer\s+(.+)$/i);
  const token = match?.[1];
  if (!token || token !== config.token) {
    throw new ApiError(401, "unauthorized", "Invalid bearer token");
  }
  const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
  return { type: "remote", clientId, tokenHash: hashToken(token) };
}

function requireHost(request: Request, config: ServerConfig): Actor {
  const token = request.headers.get("x-openwork-host-token");
  if (!token || token !== config.hostToken) {
    throw new ApiError(401, "unauthorized", "Invalid host token");
  }
  return { type: "host", tokenHash: hashToken(token) };
}

function buildCapabilities(config: ServerConfig): Capabilities {
  const writeEnabled = !config.readOnly;
  return {
    skills: { read: true, write: writeEnabled, source: "openwork" },
    plugins: { read: true, write: writeEnabled },
    mcp: { read: true, write: writeEnabled },
    commands: { read: true, write: writeEnabled },
    config: { read: true, write: writeEnabled },
  };
}

function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) {
  const opencode =
    workspace.baseUrl || workspace.directory
      ? {
          baseUrl: workspace.baseUrl,
          directory: workspace.directory,
        }
      : undefined;
  return {
    ...workspace,
    opencode,
  };
}

function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
  const routes: Route[] = [];

  addRoute(routes, "GET", "/health", "none", async () => {
    return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
  });

  addRoute(routes, "GET", "/capabilities", "client", async () => {
    return jsonResponse(buildCapabilities(config));
  });

  addRoute(routes, "GET", "/workspaces", "client", async () => {
    const active = config.workspaces[0];
    const items = active ? [serializeWorkspace(active)] : [];
    return jsonResponse({ items });
  });

  addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const opencode = await readOpencodeConfig(workspace.path);
    const openwork = await readOpenworkConfig(workspace.path);
    const lastAudit = await readLastAudit(workspace.path);
    return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
  });

  addRoute(routes, "GET", "/workspace/:id/audit", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const limitParam = ctx.url.searchParams.get("limit");
    const parsed = limitParam ? Number(limitParam) : NaN;
    const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
    const items = await readAuditEntries(workspace.path, limit);
    return jsonResponse({ items });
  });

  addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    const opencode = body.opencode as Record<string, unknown> | undefined;
    const openwork = body.openwork as Record<string, unknown> | undefined;

    if (!opencode && !openwork) {
      throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
    }

    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "config.patch",
      summary: "Patch workspace config",
      paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean) as string[],
    });

    if (opencode) {
      await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
    }
    if (openwork) {
      await writeOpenworkConfig(workspace.path, openwork, true);
    }

    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "config.patch",
      target: "opencode.json",
      summary: "Patched workspace config",
      timestamp: Date.now(),
    });

    return jsonResponse({ updatedAt: Date.now() });
  });

  addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
    const result = await listPlugins(workspace.path, includeGlobal);
    return jsonResponse(result);
  });

  addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    const spec = String(body.spec ?? "");
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "plugins.add",
      summary: `Add plugin ${spec}`,
      paths: [opencodeConfigPath(workspace.path)],
    });
    await addPlugin(workspace.path, spec);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "plugins.add",
      target: "opencode.json",
      summary: `Added ${spec}`,
      timestamp: Date.now(),
    });
    const result = await listPlugins(workspace.path, false);
    return jsonResponse(result);
  });

  addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const name = ctx.params.name ?? "";
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "plugins.remove",
      summary: `Remove plugin ${name}`,
      paths: [opencodeConfigPath(workspace.path)],
    });
    await removePlugin(workspace.path, name);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "plugins.remove",
      target: "opencode.json",
      summary: `Removed ${name}`,
      timestamp: Date.now(),
    });
    const result = await listPlugins(workspace.path, false);
    return jsonResponse(result);
  });

  addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
    const items = await listSkills(workspace.path, includeGlobal);
    return jsonResponse({ items });
  });

  addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    const name = String(body.name ?? "");
    const content = String(body.content ?? "");
    const description = body.description ? String(body.description) : undefined;
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "skills.upsert",
      summary: `Upsert skill ${name}`,
      paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
    });
    const path = await upsertSkill(workspace.path, { name, content, description });
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "skills.upsert",
      target: path,
      summary: `Upserted skill ${name}`,
      timestamp: Date.now(),
    });
    return jsonResponse({ name, path, description: description ?? "", scope: "project" });
  });

  addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const items = await listMcp(workspace.path);
    return jsonResponse({ items });
  });

  addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    const name = String(body.name ?? "");
    const configPayload = body.config as Record<string, unknown> | undefined;
    if (!configPayload) {
      throw new ApiError(400, "invalid_payload", "MCP config is required");
    }
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "mcp.add",
      summary: `Add MCP ${name}`,
      paths: [opencodeConfigPath(workspace.path)],
    });
    await addMcp(workspace.path, name, configPayload);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "mcp.add",
      target: "opencode.json",
      summary: `Added MCP ${name}`,
      timestamp: Date.now(),
    });
    const items = await listMcp(workspace.path);
    return jsonResponse({ items });
  });

  addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const name = ctx.params.name ?? "";
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "mcp.remove",
      summary: `Remove MCP ${name}`,
      paths: [opencodeConfigPath(workspace.path)],
    });
    await removeMcp(workspace.path, name);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "mcp.remove",
      target: "opencode.json",
      summary: `Removed MCP ${name}`,
      timestamp: Date.now(),
    });
    const items = await listMcp(workspace.path);
    return jsonResponse({ items });
  });

  addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
    const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
    if (scope === "global") {
      requireHost(ctx.request, config);
    }
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const items = await listCommands(workspace.path, scope);
    return jsonResponse({ items });
  });

  addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    const name = String(body.name ?? "");
    const template = String(body.template ?? "");
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "commands.upsert",
      summary: `Upsert command ${name}`,
      paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
    });
    const path = await upsertCommand(workspace.path, {
      name,
      description: body.description ? String(body.description) : undefined,
      template,
      agent: body.agent ? String(body.agent) : undefined,
      model: body.model ? String(body.model) : undefined,
      subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
    });
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "commands.upsert",
      target: path,
      summary: `Upserted command ${name}`,
      timestamp: Date.now(),
    });
    const items = await listCommands(workspace.path, "workspace");
    return jsonResponse({ items });
  });

  addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const name = ctx.params.name ?? "";
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "commands.delete",
      summary: `Delete command ${name}`,
      paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
    });
    await deleteCommand(workspace.path, name);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "commands.delete",
      target: join(workspace.path, ".opencode", "commands"),
      summary: `Deleted command ${name}`,
      timestamp: Date.now(),
    });
    return jsonResponse({ ok: true });
  });

  addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const exportPayload = await exportWorkspace(workspace);
    return jsonResponse(exportPayload);
  });

  addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
    ensureWritable(config);
    const workspace = await resolveWorkspace(config, ctx.params.id);
    const body = await readJsonBody(ctx.request);
    await requireApproval(ctx, {
      workspaceId: workspace.id,
      action: "config.import",
      summary: "Import workspace config",
      paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
    });
    await importWorkspace(workspace, body);
    await recordAudit(workspace.path, {
      id: shortId(),
      workspaceId: workspace.id,
      actor: ctx.actor ?? { type: "remote" },
      action: "config.import",
      target: "workspace",
      summary: "Imported workspace config",
      timestamp: Date.now(),
    });
    return jsonResponse({ ok: true });
  });

  addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
    return jsonResponse({ items: ctx.approvals.list() });
  });

  addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
    const body = await readJsonBody(ctx.request);
    const reply = body.reply === "allow" ? "allow" : "deny";
    const result = ctx.approvals.respond(ctx.params.id, reply);
    if (!result) {
      throw new ApiError(404, "approval_not_found", "Approval request not found");
    }
    return jsonResponse({ ok: true, allowed: result.allowed });
  });

  return routes;
}

async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
  const workspace = config.workspaces.find((entry) => entry.id === id);
  if (!workspace) {
    throw new ApiError(404, "workspace_not_found", "Workspace not found");
  }
  const resolvedWorkspace = resolve(workspace.path);
  const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
  if (!authorized) {
    throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
  }
  return { ...workspace, path: resolvedWorkspace };
}

async function isAuthorizedRoot(workspacePath: string, roots: string[]): Promise<boolean> {
  const resolvedWorkspace = resolve(workspacePath);
  for (const root of roots) {
    const resolvedRoot = resolve(root);
    if (resolvedWorkspace === resolvedRoot) return true;
    if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;
  }
  return false;
}

function ensureWritable(config: ServerConfig): void {
  if (config.readOnly) {
    throw new ApiError(403, "read_only", "Server is read-only");
  }
}

async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
  try {
    const json = await request.json();
    return json as Record<string, unknown>;
  } catch {
    throw new ApiError(400, "invalid_json", "Invalid JSON body");
  }
}

async function readOpencodeConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
  const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
  return data;
}

async function readOpenworkConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
  const path = openworkConfigPath(workspaceRoot);
  if (!(await exists(path))) return {};
  try {
    const raw = await readFile(path, "utf8");
    return JSON.parse(raw) as Record<string, unknown>;
  } catch {
    throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
  }
}

async function writeOpenworkConfig(workspaceRoot: string, payload: Record<string, unknown>, merge: boolean): Promise<void> {
  const path = openworkConfigPath(workspaceRoot);
  const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
  await ensureDir(join(workspaceRoot, ".opencode"));
  await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
}

async function requireApproval(
  ctx: RequestContext,
  input: Omit<ApprovalRequest, "id" | "createdAt" | "actor">,
): Promise<void> {
  const actor = ctx.actor ?? { type: "remote" };
  const result = await ctx.approvals.requestApproval({ ...input, actor });
  if (!result.allowed) {
    throw new ApiError(403, "write_denied", "Write request denied", {
      requestId: result.id,
      reason: result.reason,
    });
  }
}

async function exportWorkspace(workspace: WorkspaceInfo) {
  const opencode = await readOpencodeConfig(workspace.path);
  const openwork = await readOpenworkConfig(workspace.path);
  const skills = await listSkills(workspace.path, false);
  const commands = await listCommands(workspace.path, "workspace");
  const skillContents = await Promise.all(
    skills.map(async (skill) => ({
      name: skill.name,
      description: skill.description,
      content: await readFile(skill.path, "utf8"),
    })),
  );
  const commandContents = await Promise.all(
    commands.map(async (command) => ({
      name: command.name,
      description: command.description,
      template: command.template,
    })),
  );

  return {
    workspaceId: workspace.id,
    exportedAt: Date.now(),
    opencode,
    openwork,
    skills: skillContents,
    commands: commandContents,
  };
}

async function importWorkspace(workspace: WorkspaceInfo, payload: Record<string, unknown>): Promise<void> {
  const modes = (payload.mode as Record<string, string> | undefined) ?? {};
  const opencode = payload.opencode as Record<string, unknown> | undefined;
  const openwork = payload.openwork as Record<string, unknown> | undefined;
  const skills = (payload.skills as { name: string; content: string; description?: string }[] | undefined) ?? [];
  const commands = (payload.commands as { name: string; content?: string; description?: string; template?: string; agent?: string; model?: string | null; subtask?: boolean }[] | undefined) ?? [];

  if (opencode) {
    if (modes.opencode === "replace") {
      await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
    } else {
      await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
    }
  }

  if (openwork) {
    if (modes.openwork === "replace") {
      await writeOpenworkConfig(workspace.path, openwork, false);
    } else {
      await writeOpenworkConfig(workspace.path, openwork, true);
    }
  }

  if (skills.length > 0) {
    if (modes.skills === "replace") {
      await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
    }
    for (const skill of skills) {
      await upsertSkill(workspace.path, skill);
    }
  }

  if (commands.length > 0) {
    if (modes.commands === "replace") {
      await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
    }
    for (const command of commands) {
      if (command.content) {
        const parsed = parseFrontmatter(command.content);
        const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
        const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
        if (!name) {
          throw new ApiError(400, "invalid_command", "Command name is required");
        }
        const template = parsed.body.trim();
        await upsertCommand(workspace.path, {
          name,
          description,
          template,
          agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
          model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
          subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
        });
      } else {
        const name = command.name ?? "";
        const template = command.template ?? "";
        await upsertCommand(workspace.path, {
          name,
          description: command.description,
          template,
          agent: command.agent,
          model: command.model,
          subtask: command.subtask,
        });
      }
    }
  }
}



================================================
FILE: packages/server/src/skills.ts
================================================
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import type { SkillItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { validateDescription, validateSkillName } from "./validators.js";
import { ApiError } from "./errors.js";
import { projectSkillsDir } from "./workspace-files.js";

async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
  const roots: string[] = [];
  let current = resolve(workspaceRoot);
  while (true) {
    roots.push(current);
    const gitPath = join(current, ".git");
    if (await exists(gitPath)) break;
    const parent = resolve(current, "..");
    if (parent === current) break;
    current = parent;
  }
  return roots;
}

async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
  if (!(await exists(dir))) return [];
  const entries = await readdir(dir, { withFileTypes: true });
  const items: SkillItem[] = [];
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const skillPath = join(dir, entry.name, "SKILL.md");
    if (!(await exists(skillPath))) continue;
    const content = await readFile(skillPath, "utf8");
    const { data } = parseFrontmatter(content);
    const name = typeof data.name === "string" ? data.name : entry.name;
    const description = typeof data.description === "string" ? data.description : "";
    try {
      validateSkillName(name);
      validateDescription(description);
    } catch {
      continue;
    }
    if (name !== entry.name) continue;
    items.push({
      name,
      description,
      path: skillPath,
      scope,
    });
  }
  return items;
}

export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
  const roots = await findWorkspaceRoots(workspaceRoot);
  const items: SkillItem[] = [];
  for (const root of roots) {
    const opencodeDir = join(root, ".opencode", "skills");
    const claudeDir = join(root, ".claude", "skills");
    items.push(...(await listSkillsInDir(opencodeDir, "project")));
    items.push(...(await listSkillsInDir(claudeDir, "project")));
  }

  if (includeGlobal) {
    const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
    const globalClaude = join(homedir(), ".claude", "skills");
    items.push(...(await listSkillsInDir(globalOpenWork, "global")));
    items.push(...(await listSkillsInDir(globalClaude, "global")));
  }

  const seen = new Set<string>();
  return items.filter((item) => {
    if (seen.has(item.name)) return false;
    seen.add(item.name);
    return true;
  });
}

export async function upsertSkill(
  workspaceRoot: string,
  payload: { name: string; content: string; description?: string },
): Promise<string> {
  const name = payload.name.trim();
  validateSkillName(name);
  if (!payload.content) {
    throw new ApiError(400, "invalid_skill_content", "Skill content is required");
  }

  let content = payload.content;
  const { data, body } = parseFrontmatter(payload.content);
  if (Object.keys(data).length > 0) {
    const frontmatterName = typeof data.name === "string" ? data.name : "";
    const frontmatterDescription = typeof data.description === "string" ? data.description : "";
    if (frontmatterName && frontmatterName !== name) {
      throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
    }
    validateDescription(frontmatterDescription || payload.description);
    const nextDescription = frontmatterDescription || payload.description || "";
    const frontmatter = buildFrontmatter({
      ...data,
      name,
      description: nextDescription,
    });
    content = frontmatter + body.replace(/^\n/, "");
  } else {
    validateDescription(payload.description);
    const frontmatter = buildFrontmatter({ name, description: payload.description });
    content = frontmatter + payload.content.replace(/^\n/, "");
  }

  const baseDir = projectSkillsDir(workspaceRoot);
  const skillDir = join(baseDir, name);
  await mkdir(skillDir, { recursive: true });
  const skillPath = join(skillDir, "SKILL.md");
  await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
  return skillPath;
}



================================================
FILE: packages/server/src/types.ts
================================================
export type WorkspaceType = "local" | "remote";

export type ApprovalMode = "manual" | "auto";

export interface WorkspaceConfig {
  path: string;
  name?: string;
  workspaceType?: WorkspaceType;
  baseUrl?: string;
  directory?: string;
}

export interface WorkspaceInfo {
  id: string;
  name: string;
  path: string;
  workspaceType: WorkspaceType;
  baseUrl?: string;
  directory?: string;
  opencode?: {
    baseUrl?: string;
    directory?: string;
  };
}

export interface ApprovalConfig {
  mode: ApprovalMode;
  timeoutMs: number;
}

export interface ServerConfig {
  host: string;
  port: number;
  token: string;
  hostToken: string;
  approval: ApprovalConfig;
  corsOrigins: string[];
  workspaces: WorkspaceInfo[];
  authorizedRoots: string[];
  readOnly: boolean;
  startedAt: number;
  tokenSource: "cli" | "env" | "file" | "generated";
  hostTokenSource: "cli" | "env" | "file" | "generated";
}

export interface Capabilities {
  skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
  plugins: { read: boolean; write: boolean };
  mcp: { read: boolean; write: boolean };
  commands: { read: boolean; write: boolean };
  config: { read: boolean; write: boolean };
}

export interface ApiErrorBody {
  code: string;
  message: string;
  details?: unknown;
}

export interface PluginItem {
  spec: string;
  source: "config" | "dir.project" | "dir.global";
  scope: "project" | "global";
  path?: string;
}

export interface McpItem {
  name: string;
  config: Record<string, unknown>;
  source: "config.project" | "config.global" | "config.remote";
  disabledByTools?: boolean;
}

export interface SkillItem {
  name: string;
  path: string;
  description: string;
  scope: "project" | "global";
}

export interface CommandItem {
  name: string;
  description?: string;
  template: string;
  agent?: string;
  model?: string | null;
  subtask?: boolean;
  scope: "workspace" | "global";
}

export interface Actor {
  type: "remote" | "host";
  clientId?: string;
  tokenHash?: string;
}

export interface ApprovalRequest {
  id: string;
  workspaceId: string;
  action: string;
  summary: string;
  paths: string[];
  createdAt: number;
  actor: Actor;
}

export interface AuditEntry {
  id: string;
  workspaceId: string;
  actor: Actor;
  action: string;
  target: string;
  summary: string;
  timestamp: number;
}



================================================
FILE: packages/server/src/utils.ts
================================================
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, stat } from "node:fs/promises";

export async function exists(path: string): Promise<boolean> {
  try {
    await stat(path);
    return true;
  } catch {
    return false;
  }
}

export async function ensureDir(path: string): Promise<void> {
  await mkdir(path, { recursive: true });
}

export async function readJsonFile<T>(path: string): Promise<T | null> {
  try {
    const raw = await readFile(path, "utf8");
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

export function hashToken(token: string): string {
  return createHash("sha256").update(token).digest("hex");
}

export function shortId(): string {
  return randomUUID();
}

export function parseList(input: string | undefined): string[] {
  if (!input) return [];
  const trimmed = input.trim();
  if (!trimmed) return [];
  if (trimmed.startsWith("[")) {
    try {
      const parsed = JSON.parse(trimmed);
      if (Array.isArray(parsed)) {
        return parsed.map((item) => String(item)).filter(Boolean);
      }
    } catch {
      return [];
    }
  }
  return trimmed
    .split(/[,;]/)
    .map((value) => value.trim())
    .filter(Boolean);
}



================================================
FILE: packages/server/src/validators.ts
================================================
import { ApiError } from "./errors.js";

const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;

export function validateSkillName(name: string): void {
  if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
    throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
  }
}

export function validateDescription(description: string | undefined): void {
  if (!description || description.length < 1 || description.length > 1024) {
    throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
  }
}

export function validatePluginSpec(spec: string): void {
  if (!spec || spec.trim().length === 0) {
    throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
  }
}

export function sanitizeCommandName(name: string): string {
  const trimmed = name.trim().replace(/^\/+/, "");
  return trimmed;
}

export function validateCommandName(name: string): void {
  if (!name || !COMMAND_NAME_REGEX.test(name)) {
    throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
  }
}

export function validateMcpName(name: string): void {
  if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
    throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
  }
}

export function validateMcpConfig(config: Record<string, unknown>): void {
  const type = config.type;
  if (type !== "local" && type !== "remote") {
    throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
  }
  if (type === "local") {
    const command = config.command;
    if (!Array.isArray(command) || command.length === 0) {
      throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
    }
  }
  if (type === "remote") {
    const url = config.url;
    if (!url || typeof url !== "string") {
      throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
    }
  }
}



================================================
FILE: packages/server/src/workspace-files.ts
================================================
import { existsSync } from "node:fs";
import { join } from "node:path";

export function opencodeConfigPath(workspaceRoot: string): string {
  const jsoncPath = join(workspaceRoot, "opencode.jsonc");
  const jsonPath = join(workspaceRoot, "opencode.json");
  if (existsSync(jsoncPath)) return jsoncPath;
  if (existsSync(jsonPath)) return jsonPath;
  return jsoncPath;
}

export function openworkConfigPath(workspaceRoot: string): string {
  return join(workspaceRoot, ".opencode", "openwork.json");
}

export function projectSkillsDir(workspaceRoot: string): string {
  return join(workspaceRoot, ".opencode", "skills");
}

export function projectCommandsDir(workspaceRoot: string): string {
  return join(workspaceRoot, ".opencode", "commands");
}

export function projectPluginsDir(workspaceRoot: string): string {
  return join(workspaceRoot, ".opencode", "plugins");
}



================================================
FILE: packages/server/src/workspaces.ts
================================================
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";

export function workspaceIdForPath(path: string): string {
  const hash = createHash("sha256").update(path).digest("hex");
  return `ws_${hash.slice(0, 12)}`;
}

export function buildWorkspaceInfos(
  workspaces: WorkspaceConfig[],
  cwd: string,
): WorkspaceInfo[] {
  return workspaces.map((workspace) => {
    const resolvedPath = resolve(cwd, workspace.path);
    return {
      id: workspaceIdForPath(resolvedPath),
      name: workspace.name ?? basename(resolvedPath),
      path: resolvedPath,
      workspaceType: workspace.workspaceType ?? "local",
      baseUrl: workspace.baseUrl,
      directory: workspace.directory,
    };
  });
}


