diff --git a/.opencode/skills/local-opencode-override/SKILL.md b/.opencode/skills/local-opencode-override/SKILL.md new file mode 100644 index 000000000000..c066592d911c --- /dev/null +++ b/.opencode/skills/local-opencode-override/SKILL.md @@ -0,0 +1,80 @@ +--- +name: local-opencode-override +description: Use when a repo-local opencode build should replace the installed `opencode` command in your shell, especially after rebuilding `packages/opencode/dist` during local development. +compatibility: opencode +--- + +# Local opencode override + +Use this when you want `opencode` in your shell to run the binary built from this repo instead of a globally installed copy. + +## Preferred approach + +Follow the existing OpenCode convention and point `~/.opencode/bin/opencode` at the repo build output. + +Why this path: + +- the install flow already uses `~/.opencode/bin` +- desktop code and GitHub actions already expect that location +- your shell can keep using `opencode` with no extra alias + +## Steps + +1. Build the current-platform CLI: + + ```bash + bun run --cwd packages/opencode build --single --skip-embed-web-ui + ``` + +2. Link the built binary into the standard user bin location: + + ```bash + mkdir -p "$HOME/.opencode/bin" + ln -sf \ + "/absolute/path/to/repo/packages/opencode/dist/opencode-/bin/opencode" \ + "$HOME/.opencode/bin/opencode" + ``` + + Example for this repo on Apple Silicon: + + ```bash + ln -sf \ + "/Users/jairadhakrishnan/github.com/jairad26/opencode/packages/opencode/dist/opencode-darwin-arm64/bin/opencode" \ + "$HOME/.opencode/bin/opencode" + ``` + +3. Make sure `~/.opencode/bin` is early in `PATH`. + + For zsh: + + ```bash + export PATH="$HOME/.opencode/bin:$PATH" + ``` + +4. Reload the shell and verify: + + ```bash + zsh -lc 'hash -r && command -v opencode && opencode --version' + ``` + +## Rebuild behavior + +The symlink target path stays the same across rebuilds for the same platform, so rerunning the build replaces the binary in place. + +## Alternative + +If you only want a temporary override, use the launcher support built into `packages/opencode/bin/opencode`: + +```bash +OPENCODE_BIN_PATH="/absolute/path/to/repo/packages/opencode/dist/opencode-/bin/opencode" opencode +``` + +## Revert + +To stop using the repo build: + +```bash +rm -f "$HOME/.opencode/bin/opencode" +``` + +Then reinstall or relink the version you want. diff --git a/.opencode/skills/opencode-memory/SKILL.md b/.opencode/skills/opencode-memory/SKILL.md new file mode 100644 index 000000000000..078aba443b67 --- /dev/null +++ b/.opencode/skills/opencode-memory/SKILL.md @@ -0,0 +1,274 @@ +--- +name: opencode-memory +description: Use when the user asks to recall prior OpenCode work, previous sessions, plans, prompt history, memory, or earlier project context stored on the local machine. +compatibility: opencode +--- + +# OpenCode Memory Browser + +Lightweight, read-only access to your local OpenCode history. No injection, no bloat — just the ability to look things up when it would help. + +This skill is specifically about OpenCode data stored on the local machine. It is not for ChatGPT history, Claude cloud history, generic browser history, or external memory products. + +All data lives in local SQLite databases and plain files. You query them directly using `sqlite3` via bash. No bundled scripts or external dependencies needed. + +## When to Use + +### Auto-trigger (agent decides) + +- You are resuming work on a project and suspect prior sessions exist. +- The user references something done previously ("we did this before", "last time", "that plan we made"). +- A recurring issue suggests checking if it was encountered before. +- The user asks about the state of plans, past decisions, or previous approaches. +- You need context that might exist in history but is not in the current session. + +### User-triggered (explicit request) + +- "Check my history" +- "What did we do in the last session?" +- "Show me my plans" +- "Search for when we discussed X" +- "What projects have I worked on?" +- "Look at previous conversations about Y" + +### Do NOT use when + +- The task is clearly brand new with no relevant history. +- Fresh repo context (files, git log) is sufficient. +- The user explicitly says they don't care about prior work. + +## Storage Locations + +``` +Databases: ${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode*.db +Plans: ${XDG_DATA_HOME:-$HOME/.local/share}/opencode/plans/*.md +Session diffs: ${XDG_DATA_HOME:-$HOME/.local/share}/opencode/storage/session_diff/.json +Prompt history: ${XDG_STATE_HOME:-$HOME/.local/state}/opencode/prompt-history.jsonl +``` + +The database path respects `$XDG_DATA_HOME` if set (default: `~/.local/share`). + +Important: OpenCode may store session history in multiple channel-specific databases such as: + +- `opencode.db` +- `opencode-dev.db` +- `opencode-local.db` +- other `opencode-.db` files + +When recalling prior work, search **all local `opencode*.db` files**, not just `opencode.db`. + +## Database Schema (what matters) + +- **project** — `id` (text PK), `worktree` (path), `name` (often NULL, derive from worktree basename) +- **session** — `id` (text, e.g. `ses_xxx`), `project_id` (FK), `parent_id` (NULL = main session, set = subagent), `title`, `summary`, `time_created`, `time_updated` +- **message** — `id`, `session_id` (FK), `data` (JSON with `$.role` = `"user"` or `"assistant"`), `time_created` +- **part** — `id`, `message_id` (FK), `session_id` (FK), `data` (JSON with `$.type` = `"text"` and `$.text` = content) + +Timestamps are Unix milliseconds. Use `datetime(col/1000, 'unixepoch', 'localtime')` to display them. + +## Ready-to-Use Queries + +All queries use `sqlite3` in read-only mode. Always run via bash. + +**Shorthand used below:** + +``` +DATA_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/opencode" +STATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/opencode" +DBS=("$DATA_ROOT"/opencode*.db) +``` + +If the glob does not match anything, verify the storage root first with `ls "$DATA_ROOT"`. + +### Quick summary + +```bash +for DB in "${DBS[@]}"; do + [ -f "$DB" ] || continue + DB_URI="file:${DB}?mode=ro" + printf '\n== %s ==\n' "$DB" + sqlite3 "$DB_URI" " + SELECT 'projects', COUNT(*) FROM project + UNION ALL SELECT 'sessions (main)', COUNT(*) FROM session WHERE parent_id IS NULL + UNION ALL SELECT 'sessions (total)', COUNT(*) FROM session + UNION ALL SELECT 'messages', COUNT(*) FROM message + UNION ALL SELECT 'todos', COUNT(*) FROM todo; + " +done +``` + +### List projects + +Set `DB_URI` to the database you want to inspect first, for example: + +```bash +DB="$DATA_ROOT/opencode-dev.db" +DB_URI="file:${DB}?mode=ro" +``` + +```bash +sqlite3 "$DB_URI" " + SELECT + COALESCE(p.name, CASE WHEN p.worktree = '/' THEN '(global)' ELSE REPLACE(p.worktree, RTRIM(p.worktree, REPLACE(p.worktree, '/', '')), '') END) AS name, + p.worktree, + (SELECT COUNT(*) FROM session s WHERE s.project_id = p.id AND s.parent_id IS NULL) AS sessions + FROM project p + ORDER BY p.time_updated DESC + LIMIT 10; +" +``` + +### List recent sessions + +```bash +for DB in "${DBS[@]}"; do + [ -f "$DB" ] || continue + DB_URI="file:${DB}?mode=ro" + sqlite3 "$DB_URI" " + SELECT + '${DB}' AS db, + s.id, + COALESCE(s.title, 'untitled') AS title, + COALESCE(p.name, CASE WHEN p.worktree = '/' THEN '(global)' ELSE REPLACE(p.worktree, RTRIM(p.worktree, REPLACE(p.worktree, '/', '')), '') END) AS project, + datetime(s.time_updated/1000, 'unixepoch', 'localtime') AS updated, + (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs + FROM session s + LEFT JOIN project p ON p.id = s.project_id + WHERE s.parent_id IS NULL + ORDER BY s.time_updated DESC + LIMIT 10; + " +done +``` + +### Sessions for a specific project + +Set `DB_URI` to the likely matching database, then replace the worktree path with the actual project path: + +```bash +sqlite3 "$DB_URI" " + SELECT s.id, COALESCE(s.title, 'untitled'), + datetime(s.time_updated/1000, 'unixepoch', 'localtime') + FROM session s + JOIN project p ON p.id = s.project_id + WHERE p.worktree = '/path/to/project' + AND s.parent_id IS NULL + ORDER BY s.time_updated DESC + LIMIT 10; +" +``` + +To find the worktree for the current directory: `git rev-parse --show-toplevel` + +### Read messages from a session + +Replace the session ID: + +```bash +sqlite3 "$DB_URI" " + SELECT + json_extract(m.data, '$.role') AS role, + datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, + GROUP_CONCAT(json_extract(p.data, '$.text'), char(10)) AS text + FROM message m + LEFT JOIN part p ON p.message_id = m.id + AND json_extract(p.data, '$.type') = 'text' + WHERE m.session_id = 'SESSION_ID_HERE' + GROUP BY m.id + ORDER BY m.time_created ASC + LIMIT 50; +" +``` + +### Search across all conversations + +Replace the search term: + +```bash +for DB in "${DBS[@]}"; do + [ -f "$DB" ] || continue + DB_URI="file:${DB}?mode=ro" + sqlite3 "$DB_URI" " + SELECT + '${DB}' AS db, + s.id AS session_id, + COALESCE(s.title, 'untitled') AS title, + json_extract(m.data, '$.role') AS role, + datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, + substr(json_extract(p.data, '$.text'), 1, 200) AS snippet + FROM part p + JOIN message m ON m.id = p.message_id + JOIN session s ON s.id = m.session_id + WHERE s.parent_id IS NULL + AND json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') LIKE '%SEARCH_TERM%' + ORDER BY m.time_created DESC + LIMIT 10; + " +done +``` + +### List saved plans + +```bash +ls -lt "$DATA_ROOT"/plans/*.md 2>/dev/null | head -20 +``` + +To read a specific plan: + +```bash +cat "$DATA_ROOT"/plans/FILENAME.md +``` + +### Show recent prompt history + +```bash +tail -20 "$STATE_ROOT"/prompt-history.jsonl +``` + +Each line is a JSON object. The user's input is typically in the `input` or `text` field. + +## Workflow + +### Quick recall (most common) + +1. Check **prompt history first** with `rg -n -i "term1|term2" "$STATE_ROOT/prompt-history.jsonl"` to recover the user's original wording and likely time window. +2. Run the **summary** query across all local databases to see which DB/channel has the relevant history. +3. If you need sessions for the current project, get the worktree with `git rev-parse --show-toplevel`, then run the **project sessions** query against the likely matching DB(s). +4. If you need a specific topic, run the **search** query across all DBs using both the exact phrase and adjacent terms. +5. If you need full conversation detail, run the **messages** query with the session ID from the matching DB. + +### Plan review + +1. List plans with `ls -lt "$DATA_ROOT"/plans/*.md`. +2. Read a plan with `cat "$DATA_ROOT"/plans/.md`. + +### Deep investigation + +1. Search prompt history first to anchor wording/date. +2. Run **projects/sessions** across all local DBs. +3. Search with neighboring terms, not just the user’s remembered phrasing. +4. Read only the best candidate session tails before expanding further. +5. Cross-reference with session diffs or plans if needed. + +## Critical Rules + +1. **Read-only.** Never write to or modify the database or any OpenCode files. +2. **Use bash + sqlite3.** Do not try to read `opencode*.db` with the Read tool — they are binary files. Always query via `sqlite3` in bash. +3. **Don't dump everything.** Use `LIMIT` and `LIKE` to keep output focused. The database can contain tens of thousands of messages. +4. **Summarize for the user.** After retrieving data, distill the relevant parts. Don't paste raw query output. +5. **Respect privacy.** Session history may contain sensitive data. Only surface what is relevant to the current task. +6. **Set path variables first.** At the start of any memory lookup, set `DATA_ROOT`, `STATE_ROOT`, and `DBS` exactly as shown above so the commands work on XDG and non-XDG setups and cover every local channel database. + +## Fallback: Web UI + +If the user needs visual dashboards or a browsable interface: + +1. Check if OpenCode web is running: `curl -s http://127.0.0.1:4096/api/health 2>/dev/null || echo "not running"` +2. If running, direct the user to `http://127.0.0.1:4096`. +3. If not running, suggest `opencode web`. +4. Note: `opencode.local` only works with mDNS enabled (`opencode web --mdns`). Don't assume it exists. + +## Deep Reference + +See `references/storage-format.md` for the full storage layout, all table schemas, and additional query examples. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000000..e685a7445c23 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,68 @@ +# Phone-Friendly Remote Control UX Implementation Plan + +## Context & Pain Points + +When a user is running OpenCode on their desktop but monitoring it from their phone, the current UX for handling input prompts (questions, permissions) has several friction points: + +1. **Visibility**: The user might not realize the session is blocked waiting for input if they are looking at the "changes" tab or if the prompt is scrolled out of view. +2. **Touch Targets**: While some buttons use `size="large"`, the custom input textareas and option checkboxes can be hard to tap accurately on mobile. +3. **Keyboard Obscuration**: When typing a custom answer on mobile, the virtual keyboard often obscures the prompt context or the submit button. +4. **Context Switching**: Switching between the "session" tab (to answer) and "changes" tab (to review what the agent did before asking) is cumbersome. + +## Proposed First Slice (Minimal & Incremental) + +Focus on **Visibility** and **Touch Ergonomics** for the existing `DockPrompt` components (`SessionQuestionDock` and `SessionPermissionDock`). + +### 1. Sticky/Prominent "Blocked" Indicator + +When the session is blocked waiting for input, ensure this state is immediately obvious regardless of scroll position or active tab. + +- **Implementation**: Add a sticky banner or floating action button (FAB) at the bottom of the screen (above the composer) on mobile when a prompt is active. Tapping it scrolls to the prompt or switches to the "session" tab if needed. +- **Touched Files**: + - `packages/app/src/pages/session.tsx` (to add the global indicator based on `composer.blocked()`) + - `packages/app/src/pages/session/composer/session-composer-region.tsx` (to position it relative to the composer) + +### 2. Improved Touch Targets for Options + +Make the entire option row in `SessionQuestionDock` a larger, more forgiving touch target. + +- **Implementation**: Increase padding on `[data-slot="question-option"]` in mobile views. Ensure the custom input textarea expands properly and doesn't require precise tapping to focus. +- **Touched Files**: + - `packages/ui/src/components/message-part.css` (where `[data-slot="question-option"]` is styled) + - `packages/app/src/pages/session/composer/session-question-dock.tsx` + +### 3. Auto-Scroll to Prompt on Mobile + +When a new prompt appears, automatically scroll it into view, especially on mobile where screen real estate is limited. + +- **Implementation**: Enhance the `measure` or `onMount` logic in `SessionQuestionDock` and `SessionPermissionDock` to trigger a scroll-into-view if the component is rendered and the viewport is mobile-sized. +- **Touched Files**: + - `packages/app/src/pages/session/composer/session-question-dock.tsx` + - `packages/app/src/pages/session/composer/session-permission-dock.tsx` + +## Test Approach + +1. **Unit/Component Tests**: + - Verify the "Blocked" indicator renders when `composer.blocked()` is true. + - Verify click handlers on the indicator correctly update the active tab and scroll position. +2. **E2E Tests (Playwright)**: + - Create a test simulating a mobile viewport (`isMobile: true` in Playwright config). + - Trigger a permission prompt. + - Verify the sticky indicator appears. + - Click the indicator and verify the prompt is visible. + - Interact with the larger touch targets. + +## Browser-Validation Steps (Manual) + +1. Start the backend (`bun run --conditions=browser ./src/index.ts serve --port 4096`) and frontend (`bun dev -- --port 4444`). +2. Open `http://localhost:4444` in a desktop browser. +3. Use Chrome DevTools Device Toolbar (F12 -> Ctrl+Shift+M) to simulate a mobile device (e.g., iPhone 14 Pro). +4. Start a session and trigger a command that requires permission (e.g., `bash ls`). +5. **Verify**: + - The new sticky "Blocked" indicator appears. + - Tapping it scrolls the permission dock into view. + - The "Allow" / "Deny" buttons are easily tappable. +6. Trigger a question prompt (e.g., using a test script or specific agent interaction). +7. **Verify**: + - The options have adequate padding for touch. + - Selecting a custom input option focuses the textarea without the virtual keyboard hiding the context (simulate keyboard by resizing viewport height). diff --git a/README.md b/README.md index 79ccf8b34910..ee60c4cc367a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,56 @@ Learn more about [agents](https://opencode.ai/docs/agents). For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs). +### Automation and remote control + +OpenCode can now be used for lightweight automation and remote human-in-the-loop workflows. + +#### Triggers + +List triggers: + +```bash +opencode trigger list +``` + +Create a repeating command trigger: + +```bash +opencode trigger create --interval 60000 --session ses_123 --command summarize --arguments "--daily" +``` + +Create a one-shot webhook trigger: + +```bash +opencode trigger create --at 1743600000000 --webhook https://example.com/hook --method POST --body '{"ok":true}' +``` + +Fire, enable, disable, or delete a trigger: + +```bash +opencode trigger fire +opencode trigger enable +opencode trigger disable +opencode trigger delete +``` + +#### Remote control from another device + +Run OpenCode on a machine that stays on: + +```bash +export OPENCODE_SERVER_PASSWORD='choose-a-strong-password' +opencode web --hostname 0.0.0.0 --port 4096 +``` + +From another computer, attach to it directly: + +```bash +opencode attach http://your-host:4096 --dir /path/to/project --workspace ws_123 --continue +``` + +From a phone, open the web UI in a browser. The app now surfaces blocked sessions more clearly with an awaiting-input inbox, mobile session attention states, and browser title/app-badge attention when OpenCode needs you. + ### Contributing If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000000..2ebb7746612b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,141 @@ +# OpenCode Feature Roadmap (Claude Code Parity) + +This roadmap is now ordered by practical product priority rather than original discovery order. + +Priority rules: + +- ship core parity and workflow leverage first +- prefer features that improve agent reliability, autonomy, and context handling +- push novelty/UI features later unless they unlock core usage + +## Phase 1: Core Foundations — shipped + +- [x] **1. Native Desktop Control (Computer Use Tool)** + Integrate `@nut-tree/nut-js` for native OS control: mouse movement, keystrokes, and screen capture outside the terminal. +- [x] **2. Headless Browser Automation (WebBrowserTool)** + Integrate Playwright to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM. +- [x] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** + Implement Bun background workers so the main thread can spawn independent sub-agents for parallel task execution. +- [x] **4. Long-Term Semantic Memory (SessionMemory)** + Persist user preferences, project architecture rules, and other reusable context across sessions. +- [x] **5. Strict Zod-Based Permission Gates (PermissionRouter)** + Add strict tool validation plus read-only / destructive classification and automated risk assessment. + +## Phase 2: Highest-Priority Remaining Parity + +- [!] **6. Context Window Management (BriefTool / SnipTool / context inspection)** + `brief` and safe `snip` are in flight/partially implemented. Remaining work: true context inspection, better visibility into token-heavy history, and any additional safe compaction controls. +- [x] **7. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** + Let the agent spawn and tear down temporary Git worktrees for isolated experimentation. +- [x] **8. Background Task Orchestration** + Support long-running background commands without blocking chat flows. +- [ ] **9. Scheduled & Remote Triggers (ScheduleCronTool / RemoteTriggerTool)** + Let the agent wake itself up on a schedule or be triggered by local webhooks/external events. +- [ ] **10. Remote Control & Sessions** + Continue sessions from phone/browser, remotely control a running local agent, and attach to active work without being at the terminal. +- [ ] **11. Dispatch / Handoff / Remote Runners** + Spin up new remote or background instances, hand off work to durable runners, and retrieve results later without actively driving the session. +- [ ] **12. Bridge / Remote Control & Peer Discovery** + Improve `opencode attach` with peer discovery and easier shared environment access. +- [ ] **13. SSH Remote Support** + Work on remote machines over SSH with full tool support. +- [ ] **14. Direct Connect** + Peer-to-peer remote collaboration/control without going through hosted services. +- [ ] **15. Self-Hosted Runner Support** + Deploy agents onto self-hosted infrastructure for enterprise and always-on workflows. +- [ ] **16. System Monitoring (MonitorTool)** + Read CPU, memory, disk usage, and active processes to diagnose crashes, hangs, and resource issues. +- [ ] **17. WorkflowTool** + Allow reusable workflow scripts that compose multiple tool calls into a single repeatable command. +- [ ] **18. TerminalCaptureTool** + Capture and analyze terminal output as a first-class debugging/context surface. +- [ ] **19. CtxInspectTool (ContextCollapse)** + Inspect the current context window, explain what is consuming tokens, and help the agent decide what to compact. + +## Phase 3: Proactive Agent Platform + +- [ ] **20. KAIROS & Daemon Mode (Proactive Agent)** + Turn `--serve` into a true daemon that wakes up, checks for work, and proactively opens review/work sessions. +- [ ] **21. Auto-Dream & AFK Mode** + Run idle-time memory consolidation and session review without burning active chat tokens. +- [ ] **22. Background Sessions (BG Sessions)** + Allow sessions to run fully in the background without a live TUI attached. +- [ ] **23. Specialized Modes (/advisor, /bughunter, /teleport, /ultraplan)** + Add high-leverage command modes for review, bug hunting, handoff, and heavier planning loops. +- [ ] **24. TeamCreateTool / TeamDeleteTool** + Create and manage named agent teams for coordinated swarming. +- [ ] **25. Coordinator Mode** + Add a worker registry and stronger orchestration model for long-running multi-agent execution. +- [ ] **26. Team Memory (TeamMem)** + Shared memory files and synchronization for teams operating on the same project. + +## Phase 4: Remote, Collaboration, and Reach + +- [ ] **27. RemoteTriggerTool integrations** + Tighten integration points with GitHub, Slack, and similar event sources once local triggers exist. +- [ ] **28. Mobile Companion Support** + QR flow and mobile control/mirroring support. +- [ ] **29. Chrome Extension Integration** + Browser-surface integration for web-based workflows. + +## Phase 5: UX Modes and Interface Surface Area + +- [ ] **30. Voice Mode / /voice** + Speech-to-text input and text-to-speech output. +- [ ] **31. /brief UI mode** + Toggle a brief-only transcript layout instead of full tool-output-heavy views. +- [ ] **32. Buddy (Virtual Pet) / /buddy** + ASCII companion that reacts to confidence and execution events. +- [ ] **33. /proactive and /assistant** + User-facing controls for enabling proactive behavior and assistant-mode interaction models. +- [ ] **34. /torch** + Profiling/debugging-oriented command for understanding slow operations. + +## Phase 6: Platform Completeness / Lower-Leverage Extensions + +- [ ] **35. PowerShellTool** + Full Windows-native PowerShell support with permission and safety parity. +- [ ] **36. Context7 Integration for Libraries** + Deep documentation lookup using Context7-compatible IDs. +- [ ] **37. AST-Grep Integration** + Native AST-based search and refactoring across many languages. +- [ ] **38. MCP Rich Output** + Better rendering for images, formatted results, and interactive MCP payloads. +- [ ] **39. Commit Attribution** + Track which agent produced which changes in git history. +- [ ] **40. Sandboxed Execution Mode** + Stronger execution isolation for risky/untrusted code paths. +- [ ] **41. Template System** + Project scaffolding and reusable templates. +- [ ] **42. Reactive Compact** + More dynamic context compaction based on real usage patterns. +- [ ] **43. Verification Agent** + Built-in verification-agent guidance for task/todo workflows. +- [ ] **44. Extract Memories** + Post-query learning hooks for automatic memory extraction. +- [ ] **45. Cached Microcompact** + Persist microcompact state through query and API flows. + +## Current Priority Order (short version) + +1. Finish Context Window Management +2. Add Scheduled & Remote Triggers +3. Add Remote Control & Sessions +4. Add Dispatch / Handoff / Remote Runners +5. Add System Monitoring +6. Build WorkflowTool + TerminalCaptureTool +7. Build KAIROS / daemon / AFK platform +8. Add voice / buddy / secondary UX surfaces + +## Notes + +- `brief`, safe `snip`, worktree sandboxing, and background task orchestration have already begun shifting Phase 2 upward in practical priority. +- Remote work is now split into two features: **Remote Control & Sessions** for driving a live session from afar, and **Dispatch / Handoff / Remote Runners** for sending work away to durable background or remote execution contexts. +- Duplicate roadmap entries were collapsed into one canonical location each. +- Novelty features are intentionally later than workflow, reliability, and autonomy features. + +## Legend + +- [x] Implemented +- [ ] Not yet implemented +- [!] Partially implemented / in flight diff --git a/bun.lock b/bun.lock index 767cb6da2035..6c8d1a4866b7 100644 --- a/bun.lock +++ b/bun.lock @@ -330,6 +330,10 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", + "@nut-tree-fork/libnut": "4.2.6", + "@nut-tree-fork/libnut-darwin": "2.7.5", + "@nut-tree-fork/nut-js": "4.2.6", + "@nut-tree-fork/shared": "4.2.6", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -351,6 +355,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "chromium-bidi": "15.0.0", "clipboardy": "4.0.0", "cross-spawn": "^7.0.6", "decimal.js": "10.5.0", @@ -373,6 +378,7 @@ "opencode-poe-auth": "0.0.1", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", + "playwright": "1.58.2", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", @@ -1201,12 +1207,20 @@ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + "@jimp/custom": ["@jimp/custom@0.22.12", "", { "dependencies": { "@jimp/core": "^0.22.12" } }, "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q=="], + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + "@jimp/gif": ["@jimp/gif@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "gifwrap": "^0.10.1", "omggif": "^1.0.9" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg=="], + + "@jimp/jpeg": ["@jimp/jpeg@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "jpeg-js": "^0.4.4" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q=="], + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], @@ -1239,10 +1253,16 @@ "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + "@jimp/plugin-gaussian": ["@jimp/plugin-gaussian@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg=="], + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + "@jimp/plugin-invert": ["@jimp/plugin-invert@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ=="], + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + "@jimp/plugin-normalize": ["@jimp/plugin-normalize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA=="], + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], @@ -1251,8 +1271,18 @@ "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + "@jimp/plugin-scale": ["@jimp/plugin-scale@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw=="], + + "@jimp/plugin-shadow": ["@jimp/plugin-shadow@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blur": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg=="], + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + "@jimp/plugins": ["@jimp/plugins@0.22.12", "", { "dependencies": { "@jimp/plugin-blit": "^0.22.12", "@jimp/plugin-blur": "^0.22.12", "@jimp/plugin-circle": "^0.22.12", "@jimp/plugin-color": "^0.22.12", "@jimp/plugin-contain": "^0.22.12", "@jimp/plugin-cover": "^0.22.12", "@jimp/plugin-crop": "^0.22.12", "@jimp/plugin-displace": "^0.22.12", "@jimp/plugin-dither": "^0.22.12", "@jimp/plugin-fisheye": "^0.22.12", "@jimp/plugin-flip": "^0.22.12", "@jimp/plugin-gaussian": "^0.22.12", "@jimp/plugin-invert": "^0.22.12", "@jimp/plugin-mask": "^0.22.12", "@jimp/plugin-normalize": "^0.22.12", "@jimp/plugin-print": "^0.22.12", "@jimp/plugin-resize": "^0.22.12", "@jimp/plugin-rotate": "^0.22.12", "@jimp/plugin-scale": "^0.22.12", "@jimp/plugin-shadow": "^0.22.12", "@jimp/plugin-threshold": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww=="], + + "@jimp/png": ["@jimp/png@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "pngjs": "^6.0.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg=="], + + "@jimp/tiff": ["@jimp/tiff@0.22.12", "", { "dependencies": { "utif2": "^4.0.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg=="], + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], @@ -1377,6 +1407,24 @@ "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + "@nut-tree-fork/default-clipboard-provider": ["@nut-tree-fork/default-clipboard-provider@4.2.6", "", { "dependencies": { "clipboardy": "2.3.0" } }, "sha512-Hzqj57rheIMGtsS4zK4//kOhaX5FxMluOiz+4TVaHXx+idZS/bPhZwd8e6o1w1GT0PVJOUIP+4CdUe//k5VRig=="], + + "@nut-tree-fork/libnut": ["@nut-tree-fork/libnut@4.2.6", "", { "dependencies": { "@nut-tree-fork/libnut-darwin": "2.7.5", "@nut-tree-fork/libnut-linux": "2.7.5", "@nut-tree-fork/libnut-win32": "2.7.5" } }, "sha512-2FCiTBokMGrMl4eL/trEIO+mtpkXpdPHoVKdTBmW8UBIbhCbrCKmnXb2skWGfVs+U3q7o5EYDjVTNUYaUWbaxQ=="], + + "@nut-tree-fork/libnut-darwin": ["@nut-tree-fork/libnut-darwin@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q=="], + + "@nut-tree-fork/libnut-linux": ["@nut-tree-fork/libnut-linux@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg=="], + + "@nut-tree-fork/libnut-win32": ["@nut-tree-fork/libnut-win32@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw=="], + + "@nut-tree-fork/node-mac-permissions": ["@nut-tree-fork/node-mac-permissions@2.2.1", "", { "dependencies": { "bindings": "1.5.0", "node-addon-api": "5.0.0" }, "os": "darwin" }, "sha512-iSfOTDiBZ7VDa17PoQje5rUaZSvSAaq+XEyXCmhPuQwV5XuNU02Grv6oFhsdpz89w7+UvB/8KX/cX5IYQ5o2Bw=="], + + "@nut-tree-fork/nut-js": ["@nut-tree-fork/nut-js@4.2.6", "", { "dependencies": { "@nut-tree-fork/default-clipboard-provider": "4.2.6", "@nut-tree-fork/libnut": "4.2.6", "@nut-tree-fork/provider-interfaces": "4.2.6", "@nut-tree-fork/shared": "4.2.6", "jimp": "0.22.10", "node-abort-controller": "3.1.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg=="], + + "@nut-tree-fork/provider-interfaces": ["@nut-tree-fork/provider-interfaces@4.2.6", "", { "dependencies": { "@nut-tree-fork/shared": "4.2.6" } }, "sha512-brtRegDkLSV0sa5DUAigjWf6hCoamBNPb/hKK9AQlW+j3BxQ/8djaEdEB2cihqUh1ZjEtgPyXRqpCWSdKCX68A=="], + + "@nut-tree-fork/shared": ["@nut-tree-fork/shared@4.2.6", "", { "dependencies": { "jimp": "0.22.10", "node-abort-controller": "3.1.1" } }, "sha512-xZaa0YtJt/DDDq/i1vZkabjq8HOWzfhXieMai61cMbYD11J6VhAfhV23ZtQEM02WG7nc2LKjl4UwRnQCteikwA=="], + "@octokit/auth-app": ["@octokit/auth-app@8.0.1", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.1", "@octokit/auth-oauth-user": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg=="], "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="], @@ -1915,7 +1963,7 @@ "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }], "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], @@ -2303,6 +2351,8 @@ "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + "arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="], + "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], @@ -2421,12 +2471,16 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], + "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], @@ -2455,6 +2509,8 @@ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + "buffer-equal": ["buffer-equal@0.0.1", "", {}, "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -2511,6 +2567,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], @@ -2537,6 +2595,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chromium-bidi": ["chromium-bidi@15.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-ESWZM1u85CoeSozBXXG9M73S5tH0EjkqnFJoQ6F3MHs2YGe0CLVMaRvhGxetLP6w4GVR59+/cpWvDLUpLvJXLQ=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -2715,6 +2775,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devtools-protocol": ["devtools-protocol@0.0.1604597", "", {}, "sha512-7DH4+FDIwg5AxeW+kvFb5qxJuDLSNK2S9FurqLpggMrUxS3tlvN/J2kP6uOghn584shRnvKheKSSvS4bgnzWYA=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], @@ -2739,6 +2801,8 @@ "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], @@ -2953,6 +3017,8 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -3057,6 +3123,8 @@ "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -3263,6 +3331,8 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -3325,6 +3395,8 @@ "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], @@ -3443,6 +3515,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], @@ -3653,6 +3727,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "miniflare": ["miniflare@4.20251118.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251118.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-uLSAE/DvOm392fiaig4LOaatxLjM7xzIniFRG5Y3yF9IduOYLLK/pkCPQNCgKQH3ou0YJRHnTN+09LPfqYNTQQ=="], @@ -3675,6 +3751,8 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], @@ -3721,6 +3799,8 @@ "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], + "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], + "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -3729,6 +3809,8 @@ "node-abi": ["node-abi@4.26.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw=="], + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], @@ -3859,6 +3941,8 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-headers": ["parse-headers@2.0.6", "", {}, "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A=="], + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -3903,6 +3987,8 @@ "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -3931,9 +4017,9 @@ "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], @@ -4077,6 +4163,8 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -4377,6 +4465,8 @@ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], + "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -4441,6 +4531,8 @@ "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + "timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], @@ -4703,6 +4795,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4741,6 +4835,8 @@ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xhr": ["xhr@2.6.0", "", { "dependencies": { "global": "~4.4.0", "is-function": "^1.0.1", "parse-headers": "^2.0.0", "xtend": "^4.0.0" } }, "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], @@ -4749,6 +4845,8 @@ "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -5053,8 +5151,16 @@ "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/bmp/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/custom/@jimp/core": ["@jimp/core@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "any-base": "^1.1.0", "buffer": "^5.2.0", "exif-parser": "^0.1.12", "file-type": "^16.5.4", "isomorphic-fetch": "^3.0.0", "pixelmatch": "^4.0.2", "tinycolor2": "^1.6.0" } }, "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA=="], + + "@jimp/gif/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/jpeg/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -5073,8 +5179,14 @@ "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/plugin-gaussian/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugin-invert/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/plugin-normalize/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -5083,8 +5195,48 @@ "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/plugin-scale/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugin-shadow/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/plugins/@jimp/plugin-blit": ["@jimp/plugin-blit@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ=="], + + "@jimp/plugins/@jimp/plugin-blur": ["@jimp/plugin-blur@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw=="], + + "@jimp/plugins/@jimp/plugin-circle": ["@jimp/plugin-circle@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg=="], + + "@jimp/plugins/@jimp/plugin-color": ["@jimp/plugin-color@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA=="], + + "@jimp/plugins/@jimp/plugin-contain": ["@jimp/plugin-contain@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ=="], + + "@jimp/plugins/@jimp/plugin-cover": ["@jimp/plugin-cover@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA=="], + + "@jimp/plugins/@jimp/plugin-crop": ["@jimp/plugin-crop@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw=="], + + "@jimp/plugins/@jimp/plugin-displace": ["@jimp/plugin-displace@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA=="], + + "@jimp/plugins/@jimp/plugin-dither": ["@jimp/plugin-dither@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw=="], + + "@jimp/plugins/@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q=="], + + "@jimp/plugins/@jimp/plugin-flip": ["@jimp/plugin-flip@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-rotate": ">=0.3.5" } }, "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q=="], + + "@jimp/plugins/@jimp/plugin-mask": ["@jimp/plugin-mask@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA=="], + + "@jimp/plugins/@jimp/plugin-print": ["@jimp/plugin-print@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "load-bmfont": "^1.4.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5" } }, "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ=="], + + "@jimp/plugins/@jimp/plugin-resize": ["@jimp/plugin-resize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg=="], + + "@jimp/plugins/@jimp/plugin-rotate": ["@jimp/plugin-rotate@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA=="], + + "@jimp/plugins/@jimp/plugin-threshold": ["@jimp/plugin-threshold@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-color": ">=0.8.0", "@jimp/plugin-resize": ">=0.8.0" } }, "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw=="], + + "@jimp/png/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5113,6 +5265,14 @@ "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@nut-tree-fork/default-clipboard-provider/clipboardy": ["clipboardy@2.3.0", "", { "dependencies": { "arch": "^2.1.1", "execa": "^1.0.0", "is-wsl": "^2.1.1" } }, "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ=="], + + "@nut-tree-fork/node-mac-permissions/node-addon-api": ["node-addon-api@5.0.0", "", {}, "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA=="], + + "@nut-tree-fork/nut-js/jimp": ["jimp@0.22.10", "", { "dependencies": { "@jimp/custom": "^0.22.10", "@jimp/plugins": "^0.22.10", "@jimp/types": "^0.22.10", "regenerator-runtime": "^0.13.3" } }, "sha512-lCaHIJAgTOsplyJzC1w/laxSxrbSsEBw4byKwXgUdMmh+ayPsnidTblenQm+IvhIs44Gcuvlb6pd2LQ0wcKaKg=="], + + "@nut-tree-fork/shared/jimp": ["jimp@0.22.10", "", { "dependencies": { "@jimp/custom": "^0.22.10", "@jimp/plugins": "^0.22.10", "@jimp/types": "^0.22.10", "regenerator-runtime": "^0.13.3" } }, "sha512-lCaHIJAgTOsplyJzC1w/laxSxrbSsEBw4byKwXgUdMmh+ayPsnidTblenQm+IvhIs44Gcuvlb6pd2LQ0wcKaKg=="], + "@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="], "@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], @@ -5193,6 +5353,8 @@ "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@playwright/test/playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -5349,6 +5511,8 @@ "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -5467,6 +5631,8 @@ "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -5913,6 +6079,44 @@ "@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "@jimp/custom/@jimp/core/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/custom/@jimp/core/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "@jimp/custom/@jimp/core/pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="], + + "@jimp/plugins/@jimp/plugin-blit/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-blur/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-circle/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-color/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-contain/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-cover/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-crop/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-displace/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-dither/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-fisheye/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-flip/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-mask/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-print/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-resize/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-rotate/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + + "@jimp/plugins/@jimp/plugin-threshold/@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -5997,6 +6201,14 @@ "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "@nut-tree-fork/nut-js/jimp/@jimp/types": ["@jimp/types@0.22.12", "", { "dependencies": { "@jimp/bmp": "^0.22.12", "@jimp/gif": "^0.22.12", "@jimp/jpeg": "^0.22.12", "@jimp/png": "^0.22.12", "@jimp/tiff": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA=="], + + "@nut-tree-fork/shared/jimp/@jimp/types": ["@jimp/types@0.22.12", "", { "dependencies": { "@jimp/bmp": "^0.22.12", "@jimp/gif": "^0.22.12", "@jimp/jpeg": "^0.22.12", "@jimp/png": "^0.22.12", "@jimp/tiff": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA=="], + "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -6097,6 +6309,10 @@ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "@playwright/test/playwright/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6399,6 +6615,10 @@ "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@jimp/custom/@jimp/core/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "@jimp/custom/@jimp/core/pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "@jsx-email/cli/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -6451,6 +6671,16 @@ "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/get-stream": ["get-stream@4.1.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -6609,6 +6839,16 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/shebang-command": ["shebang-command@1.2.0", "", { "dependencies": { "shebang-regex": "^1.0.0" } }, "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -6651,6 +6891,10 @@ "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="], + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], + + "@nut-tree-fork/default-clipboard-provider/clipboardy/execa/cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/packages/app/src/components/settings-general.helpers.test.ts b/packages/app/src/components/settings-general.helpers.test.ts new file mode 100644 index 000000000000..2e857d997121 --- /dev/null +++ b/packages/app/src/components/settings-general.helpers.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { notificationPermissionCopy } from "./settings-general.helpers" + +describe("notificationPermissionCopy", () => { + test("offers an enable action when permission is undecided", () => { + expect(notificationPermissionCopy("default")).toEqual({ + title: "Browser notifications", + description: "Allow notifications so your phone or browser can alert you when OpenCode needs input.", + action: "Enable", + }) + }) + + test("explains denied permissions without an action", () => { + expect(notificationPermissionCopy("denied")).toEqual({ + title: "Browser notifications", + description: "Blocked in this browser. Re-enable notifications in your browser or site settings to get alerts.", + action: undefined, + }) + }) +}) diff --git a/packages/app/src/components/settings-general.helpers.ts b/packages/app/src/components/settings-general.helpers.ts new file mode 100644 index 000000000000..0e37b7dc205f --- /dev/null +++ b/packages/app/src/components/settings-general.helpers.ts @@ -0,0 +1,33 @@ +import type { NotificationPermissionState } from "@/context/platform" + +export const notificationPermissionCopy = (state: NotificationPermissionState) => { + if (state === "granted") { + return { + title: "Browser notifications", + description: "Enabled in this browser. You can get alerts when OpenCode needs your input.", + action: undefined, + } + } + + if (state === "default") { + return { + title: "Browser notifications", + description: "Allow notifications so your phone or browser can alert you when OpenCode needs input.", + action: "Enable", + } + } + + if (state === "denied") { + return { + title: "Browser notifications", + description: "Blocked in this browser. Re-enable notifications in your browser or site settings to get alerts.", + action: undefined, + } + } + + return { + title: "Browser notifications", + description: "This browser does not support system notifications.", + action: undefined, + } +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index ec0614729c92..fc6b452ee608 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -20,6 +20,7 @@ import { useSettings, } from "@/context/settings" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { notificationPermissionCopy } from "./settings-general.helpers" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -65,6 +66,10 @@ export const SettingsGeneral: Component = () => { const theme = useTheme() const language = useLanguage() const platform = usePlatform() + const [notify, { refetch: refetchNotify }] = createResource(async () => { + if (!platform.notificationPermission) return + return platform.notificationPermission() + }) const settings = useSettings() onMount(() => { @@ -410,6 +415,33 @@ export const SettingsGeneral: Component = () => { /> + + + {(state) => { + const item = () => notificationPermissionCopy(state) + return ( + + }> + + + + ) + }} + ) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3bdc46391b67..9f16faa95d3b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -8,6 +8,7 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +export type NotificationPermissionState = "unsupported" | "default" | "denied" | "granted" export type Platform = { /** Platform discriminator */ @@ -36,6 +37,8 @@ export type Platform = { /** Send a system notification (optional deep link) */ notify(title: string, description?: string, href?: string): Promise + notificationPermission?(): Promise + requestNotificationPermission?(): Promise /** Open directory picker dialog (native on Tauri, server-backed on web) */ openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d3..9cf661cbac43 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -2,7 +2,7 @@ import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" -import { type Platform, PlatformProvider } from "@/context/platform" +import { type NotificationPermissionState, type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" @@ -52,13 +52,20 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, href) => { - if (!("Notification" in window)) return +const notificationPermission = async (): Promise => { + if (!("Notification" in window)) return "unsupported" + return Notification.permission +} - const permission = - Notification.permission === "default" - ? await Notification.requestPermission().catch(() => "denied") - : Notification.permission +const requestNotificationPermission = async (): Promise => { + if (!("Notification" in window)) return "unsupported" + return Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission +} + +const notify: Platform["notify"] = async (title, description, href) => { + const permission = await requestNotificationPermission() if (permission !== "granted") return @@ -118,6 +125,8 @@ const platform: Platform = { forward, restart, notify, + notificationPermission, + requestNotificationPermission, getDefaultServer: async () => { const stored = readDefaultServerUrl() return stored ? ServerConnection.Key.make(stored) : null diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b5a96110f651..95cca2a8ed54 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -64,6 +64,9 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { + attentionTitle, + awaitingSessions, + childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -86,6 +89,7 @@ import { } from "./layout/sidebar-workspace" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +import { SessionItem } from "./layout/sidebar-items" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -429,14 +433,32 @@ export default function Layout(props: ParentProps) { onMount(() => { const toastBySession = new Map() const alertedAtBySession = new Map() + const attention = new Set() const cooldownMs = 5000 + const baseTitle = document.title + + const syncAttention = () => { + const count = attention.size + document.title = attentionTitle(baseTitle, count) + const nav = navigator as Navigator & { + setAppBadge?: (count?: number) => Promise + clearAppBadge?: () => Promise + } + if (count > 0) { + void nav.setAppBadge?.(count).catch(() => undefined) + return + } + void nav.clearAppBadge?.().catch(() => undefined) + } const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) - if (toastId === undefined) return - toaster.dismiss(toastId) - toastBySession.delete(sessionKey) + if (toastId !== undefined) { + toaster.dismiss(toastId) + toastBySession.delete(sessionKey) + } alertedAtBySession.delete(sessionKey) + if (attention.delete(sessionKey)) syncAttention() } const unsub = globalSDK.event.listen((e) => { @@ -510,6 +532,8 @@ export default function Layout(props: ParentProps) { if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return dismissSessionAlert(sessionKey) + attention.add(sessionKey) + syncAttention() const toastId = showToast({ persistent: true, @@ -530,6 +554,12 @@ export default function Layout(props: ParentProps) { toastBySession.set(sessionKey, toastId) }) onCleanup(unsub) + onCleanup(() => { + attention.clear() + document.title = baseTitle + const nav = navigator as Navigator & { clearAppBadge?: () => Promise } + void nav.clearAppBadge?.().catch(() => undefined) + }) createEffect(() => { const currentSession = params.id @@ -2067,6 +2097,12 @@ export default function Layout(props: ParentProps) { if (!item) return [] as string[] return workspaceIds(item) }) + const awaiting = createMemo(() => + workspaces().flatMap((directory) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return awaitingSessions(data, sortNow(), (item) => !permission.autoResponds(item, directory)) + }), + ) const unseenCount = createMemo(() => workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) @@ -2230,6 +2266,44 @@ export default function Layout(props: ParentProps) { when={workspacesEnabled()} fallback={ <> + 0}> +
+
+
+
+ Awaiting your input +
+
{awaiting().length}
+
+
+ + {(item) => { + const [data] = globalSync.child(item.session.directory, { bootstrap: false }) + return ( +
+
+ {item.reason === "permission" + ? language.t("notification.permission.title") + : language.t("notification.question.title")} +
+ +
+ ) + }} +
+
+
+
+
+
+ + + {/* Session panel */}
{ }) }) }) + +describe("nextMobileTab", () => { + test("switches blocked mobile views back to session", () => { + expect(nextMobileTab({ current: "changes", blocked: true, mobile: true })).toBe("session") + }) + + test("preserves the current tab when not blocked or not mobile", () => { + expect(nextMobileTab({ current: "changes", blocked: false, mobile: true })).toBe("changes") + expect(nextMobileTab({ current: "changes", blocked: true, mobile: false })).toBe("changes") + }) +}) + +describe("sessionTabAttention", () => { + test("flags the session tab when mobile changes view is blocked", () => { + expect(sessionTabAttention({ current: "changes", blocked: true, mobile: true })).toBe(true) + }) + + test("stays quiet when already on the session tab", () => { + expect(sessionTabAttention({ current: "session", blocked: true, mobile: true })).toBe(false) + }) +}) + +describe("blockedIndicatorVisible", () => { + test("shows only on mobile changes view while blocked", () => { + expect(blockedIndicatorVisible({ current: "changes", blocked: true, mobile: true })).toBe(true) + expect(blockedIndicatorVisible({ current: "session", blocked: true, mobile: true })).toBe(false) + expect(blockedIndicatorVisible({ current: "changes", blocked: false, mobile: true })).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b38..e1797ffb077c 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -19,6 +19,15 @@ type TabsInput = { export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` +export const nextMobileTab = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked ? "session" : input.current + +export const sessionTabAttention = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked && input.current !== "session" + +export const blockedIndicatorVisible = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked && input.current === "changes" + export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) diff --git a/packages/opencode/migration/20260331175554_session_memory/migration.sql b/packages/opencode/migration/20260331175554_session_memory/migration.sql new file mode 100644 index 000000000000..6cc3b7aebce8 --- /dev/null +++ b/packages/opencode/migration/20260331175554_session_memory/migration.sql @@ -0,0 +1,37 @@ +CREATE TABLE `memory_api_key` ( + `id` text PRIMARY KEY, + `provider` text NOT NULL, + `key_name` text NOT NULL, + `encrypted_value` text NOT NULL, + `description` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `memory_preference` ( + `id` text PRIMARY KEY, + `key` text NOT NULL, + `value` text NOT NULL, + `type` text NOT NULL, + `description` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `memory_rule` ( + `id` text PRIMARY KEY, + `project_id` text NOT NULL, + `pattern` text NOT NULL, + `rule` text NOT NULL, + `priority` integer DEFAULT 0 NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_memory_rule_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `memory_api_key_provider_idx` ON `memory_api_key` (`provider`);--> statement-breakpoint +CREATE INDEX `memory_api_key_name_idx` ON `memory_api_key` (`key_name`);--> statement-breakpoint +CREATE INDEX `memory_preference_key_idx` ON `memory_preference` (`key`);--> statement-breakpoint +CREATE INDEX `memory_rule_project_idx` ON `memory_rule` (`project_id`);--> statement-breakpoint +CREATE INDEX `memory_rule_pattern_idx` ON `memory_rule` (`pattern`); \ No newline at end of file diff --git a/packages/opencode/migration/20260331175554_session_memory/snapshot.json b/packages/opencode/migration/20260331175554_session_memory/snapshot.json new file mode 100644 index 000000000000..720f24f196e0 --- /dev/null +++ b/packages/opencode/migration/20260331175554_session_memory/snapshot.json @@ -0,0 +1,1681 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "00908079-404c-4d8c-b121-5016b00a5b5b", + "prevIds": [ + "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "memory_api_key", + "entityType": "tables" + }, + { + "name": "memory_preference", + "entityType": "tables" + }, + { + "name": "memory_rule", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_name", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "encrypted_value", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "memory_api_key" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "memory_preference" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pattern", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rule", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "memory_rule" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_memory_rule_project_id_project_id_fk", + "entityType": "fks", + "table": "memory_rule" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "memory_api_key_pk", + "table": "memory_api_key", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "memory_preference_pk", + "table": "memory_preference", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "memory_rule_pk", + "table": "memory_rule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memory_api_key_provider_idx", + "entityType": "indexes", + "table": "memory_api_key" + }, + { + "columns": [ + { + "value": "key_name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memory_api_key_name_idx", + "entityType": "indexes", + "table": "memory_api_key" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memory_preference_key_idx", + "entityType": "indexes", + "table": "memory_preference" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memory_rule_project_idx", + "entityType": "indexes", + "table": "memory_rule" + }, + { + "columns": [ + { + "value": "pattern", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memory_rule_pattern_idx", + "entityType": "indexes", + "table": "memory_rule" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d97046ca9419..041b382e95d9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -94,6 +94,10 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", + "@nut-tree-fork/libnut": "4.2.6", + "@nut-tree-fork/libnut-darwin": "2.7.5", + "@nut-tree-fork/nut-js": "4.2.6", + "@nut-tree-fork/shared": "4.2.6", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -115,6 +119,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "chromium-bidi": "15.0.0", "clipboardy": "4.0.0", "cross-spawn": "^7.0.6", "decimal.js": "10.5.0", @@ -137,6 +142,7 @@ "opencode-poe-auth": "0.0.1", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", + "playwright": "1.58.2", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index b104dd26774d..7f4298e03330 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -233,6 +233,8 @@ for (const item of targets) { }, }) + await Bun.file(`dist/${name}/bin/desktop.runtime.mjs`).write(await Bun.file("./src/tool/desktop.runtime.ts").text()) + // Smoke test: only run if binary is for current platform if (item.os === process.platform && item.arch === process.arch && !item.abi) { const binaryPath = `dist/${name}/bin/opencode` diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts new file mode 100644 index 000000000000..0384b6268fa9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -0,0 +1,91 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" + +type Input = { + url: string + directory?: string + workspaceID?: string + headers?: RequestInit["headers"] + fetch?: typeof globalThis.fetch +} + +type TargetInput = { + sdk: OpencodeClient + directory?: string + continue?: boolean + sessionID?: string + fork?: boolean + defer?: boolean + pick?: (items: { id: string; title?: string; parentID?: string }[]) => Promise +} + +type Target = { + baseID?: string + title?: string + picked?: boolean + remoteSessions?: { + id: string + title?: string + }[] +} + +function suffix(dir?: string) { + return dir ? ` for ${dir}` : "" +} + +function message(error: unknown) { + return error instanceof Error ? error.message : "request failed" +} + +export async function preflightRemote(input: Input): Promise { + const sdk = createOpencodeClient({ + baseUrl: input.url, + directory: input.directory, + experimental_workspaceID: input.workspaceID, + headers: input.headers, + fetch: input.fetch, + }) + + try { + const result = await sdk.path.get(undefined, { throwOnError: true }) + const data = result.data + if (!data) throw new Error("missing path data") + if (input.directory && data.directory !== input.directory) { + throw new Error(`Remote directory mismatch: expected ${input.directory} but server is using ${data.directory}`) + } + return sdk + } catch (error) { + if (error instanceof Error && error.message.startsWith("Remote directory mismatch:")) throw error + const msg = error instanceof Error ? error.message : "request failed" + throw new Error(`Failed to validate remote server at ${input.url}: ${msg}`) + } +} + +export async function resolveRemoteTarget(input: TargetInput): Promise { + if (!input.continue && !input.sessionID) return {} + + if (input.sessionID) { + await input.sdk.session.get({ sessionID: input.sessionID }, { throwOnError: true }).catch(() => { + const kind = input.fork ? "Remote fork base session" : "Remote session" + throw new Error(`${kind} "${input.sessionID}" not found${suffix(input.directory)}`) + }) + return { baseID: input.sessionID } + } + + const result = await input.sdk.session.list({ roots: true }, { throwOnError: true }).catch((error) => { + throw new Error(`Failed to resolve remote continue target${suffix(input.directory)}: ${message(error)}`) + }) + const items = (result.data ?? []).filter((item) => !item.parentID) + if (items.length > 1 && input.defer) { + return { + remoteSessions: items.map((item) => ({ + id: item.id, + title: item.title, + })), + } + } + const picked = items.length > 1 && input.pick ? await input.pick(items) : items[0]?.id + const item = items.find((item) => item.id === picked) + const baseID = item?.id + if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`) + return { baseID, title: item?.title, picked: items.length > 1 && !!input.pick } +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e8679..48b28195fd95 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" -import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" @@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { preflightRemote, resolveRemoteTarget } from "./remote" type ToolProps = { input: Tool.InferParameters @@ -289,6 +290,10 @@ export const RunCommand = cmd({ type: "string", describe: "directory to run in, path on remote server if attaching", }) + .option("workspace", { + type: "string", + describe: "workspace ID to use on the remote server when attaching", + }) .option("port", { type: "number", describe: "port for the local server (defaults to random port if no value provided)", @@ -378,8 +383,9 @@ export const RunCommand = cmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient, target?: { baseID?: string }) { + const baseID = + target?.baseID ?? (args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session) if (baseID && args.fork) { const forked = await sdk.session.fork({ sessionID: baseID }) @@ -408,7 +414,7 @@ export const RunCommand = cmd({ } } - async function execute(sdk: OpencodeClient) { + async function execute(sdk: OpencodeClient, target?: { baseID?: string }) { function tool(part: ToolPart) { try { if (part.tool === "bash") return bash(props(part)) @@ -619,7 +625,7 @@ export const RunCommand = cmd({ return args.agent })() - const sessionID = await session(sdk) + const sessionID = await session(sdk, target) if (!sessionID) { UI.error("Session not found") process.exit(1) @@ -660,8 +666,26 @@ export const RunCommand = cmd({ const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` return { Authorization: auth } })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) + const sdk = await preflightRemote({ + url: args.attach, + directory, + workspaceID: args.workspace, + headers, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) + const target = await resolveRemoteTarget({ + sdk, + directory, + continue: args.continue, + sessionID: args.session, + fork: args.fork, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) + return await execute(sdk, target) } await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8acd7480c941..60c4de6fa662 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -90,7 +90,7 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + const sessions = [...Session.listGlobal({ roots: true, limit: args.maxCount })] if (sessions.length === 0) { return diff --git a/packages/opencode/src/cli/cmd/trigger.ts b/packages/opencode/src/cli/cmd/trigger.ts new file mode 100644 index 000000000000..3a4c46eed947 --- /dev/null +++ b/packages/opencode/src/cli/cmd/trigger.ts @@ -0,0 +1,227 @@ +import type { Argv } from "yargs" +import { EOL } from "os" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { UI } from "../ui" +import { Trigger } from "../../trigger" +import { Locale } from "../../util/locale" +import { SessionID } from "../../session/schema" + +type CreateArgs = { + interval?: number + at?: number + session?: string + command?: string + arguments?: string + webhook?: string + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + body?: string + webhookSecret?: string +} + +type Action = NonNullable + +export const TriggerCommand = cmd({ + command: "trigger", + describe: "manage triggers", + builder: (yargs: Argv) => + yargs + .command(TriggerListCommand) + .command(TriggerCreateCommand) + .command(TriggerFireCommand) + .command(TriggerDeleteCommand) + .command(TriggerEnableCommand) + .command(TriggerDisableCommand) + .demandCommand(), + async handler() {}, +}) + +export const TriggerListCommand = cmd({ + command: "list", + describe: "list triggers", + builder: (yargs: Argv) => + yargs.option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const items = await Trigger.list() + if (!items.length) return + const output = args.format === "json" ? JSON.stringify(items, null, 2) : formatTriggerTable(items) + process.stdout.write(output + EOL) + }) + }, +}) + +export const TriggerCreateCommand = cmd({ + command: "create", + describe: "create a trigger", + builder: (yargs: Argv) => + yargs + .option("interval", { + describe: "interval in milliseconds", + type: "number", + }) + .option("at", { + describe: "one-time fire time as unix milliseconds", + type: "number", + }) + .option("session", { + describe: "session ID for command actions", + type: "string", + }) + .option("command", { + describe: "command to run for command actions", + type: "string", + }) + .option("arguments", { + describe: "arguments for command actions", + type: "string", + }) + .option("webhook", { + describe: "webhook URL for webhook actions", + type: "string", + }) + .option("method", { + describe: "HTTP method for webhook actions", + type: "string", + choices: ["GET", "POST", "PUT", "PATCH", "DELETE"], + }) + .option("body", { + describe: "HTTP body for webhook actions", + type: "string", + }) + .option("webhook-secret", { + describe: "secret required for external webhook firing", + type: "string", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const parsed = parseTriggerCreateInput(args as CreateArgs) + if (typeof parsed === "string") { + UI.error(parsed) + process.exit(1) + } + const item = await Trigger.create(parsed) + process.stdout.write(JSON.stringify(item, null, 2) + EOL) + }) + }, +}) + +const triggerId = (name: string, describe: string) => + cmd({ + command: `${name} `, + describe, + builder: (yargs: Argv) => + yargs.positional("id", { + describe: "trigger ID", + type: "string", + demandOption: true, + }), + async handler() {}, + }) + +export const TriggerFireCommand = { ...triggerId("fire", "fire a trigger now"), handler: runTrigger("fire") } +export const TriggerEnableCommand = { ...triggerId("enable", "enable a trigger"), handler: runTrigger("enable") } +export const TriggerDisableCommand = { ...triggerId("disable", "disable a trigger"), handler: runTrigger("disable") } +export const TriggerDeleteCommand = { ...triggerId("delete", "delete a trigger"), handler: runTrigger("delete") } + +function runTrigger(action: "fire" | "enable" | "disable" | "delete") { + return async (args: { id: string }) => { + await bootstrap(process.cwd(), async () => { + if (action === "delete") { + await Trigger.remove(args.id) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Trigger ${args.id} deleted` + UI.Style.TEXT_NORMAL) + return + } + + const item = + action === "fire" + ? await Trigger.fire(args.id) + : action === "enable" + ? await Trigger.enable(args.id) + : await Trigger.disable(args.id) + process.stdout.write(JSON.stringify(item, null, 2) + EOL) + }) + } +} + +export function parseTriggerCreateInput(args: CreateArgs): Trigger.CreateInput | string { + if (args.interval !== undefined && args.at !== undefined) return "Choose either --interval or --at, not both" + if (args.interval === undefined && args.at === undefined) return "Provide either --interval or --at" + + if (args.webhook && (args.command || args.session)) { + return "Choose either a command action (--session + --command) or a webhook action (--webhook)" + } + + let action: Action | undefined + if (args.webhook) { + action = { + type: "webhook", + url: args.webhook, + ...(args.method ? { method: args.method } : {}), + ...(args.body ? { body: args.body } : {}), + } + } + + if (args.command || args.session) { + if (!args.command || !args.session) return "Command actions require both --session and --command" + action = { + type: "command", + sessionID: SessionID.make(args.session), + command: args.command, + ...(args.arguments ? { arguments: args.arguments } : {}), + } + } + + if (args.interval !== undefined) { + const result: Trigger.CreateInput = { + interval: args.interval, + ...(action ? { action } : {}), + ...(args.webhookSecret ? { webhook_secret: args.webhookSecret } : {}), + } + return result + } + + const result: Trigger.CreateInput = { + schedule: { type: "once", at: args.at! }, + ...(action ? { action } : {}), + ...(args.webhookSecret ? { webhook_secret: args.webhookSecret } : {}), + } + return result +} + +export function formatTriggerTable(items: Trigger.Info[]) { + const lines: string[] = [] + const id = Math.max(12, ...items.map((item) => item.id.length)) + const schedule = Math.max(12, ...items.map((item) => triggerSchedule(item).length)) + const action = Math.max(12, ...items.map((item) => triggerAction(item).length)) + const state = Math.max(8, ...items.map((item) => triggerState(item).length)) + const header = `ID${" ".repeat(id - 2)} Schedule${" ".repeat(schedule - 8)} Action${" ".repeat(action - 6)} State${" ".repeat(state - 5)} Next` + lines.push(header) + lines.push("─".repeat(header.length)) + for (const item of items) { + lines.push( + `${item.id.padEnd(id)} ${triggerSchedule(item).padEnd(schedule)} ${triggerAction(item).padEnd(action)} ${triggerState(item).padEnd(state)} ${Locale.todayTimeOrDateTime(item.time.next)}`, + ) + } + return lines.join(EOL) +} + +function triggerSchedule(item: Trigger.Info) { + return item.schedule.type === "interval" ? `every ${item.schedule.interval}ms` : `once @ ${item.schedule.at}` +} + +function triggerAction(item: Trigger.Info) { + if (!item.action) return "none" + return item.action.type === "command" ? item.action.command : `${item.action.method ?? "GET"} webhook` +} + +function triggerState(item: Trigger.Info) { + if (!item.enabled) return "disabled" + if (!item.last) return "ready" + return item.last.status +} diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ec048f86b2f1..f9a5338ebc40 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -31,6 +31,11 @@ import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" +import { + DialogRemoteSessionList, + openRemoteSessionList, + selectRemoteSession, +} from "@tui/component/dialog-remote-session-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" @@ -123,6 +128,23 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +export { selectRemoteSession } + +export function getRemoteSessionCommand(input: { remote?: boolean; onSelect: () => void | Promise }) { + if (!input.remote) return + return { + title: "Browse remote sessions", + value: "remote.session.list", + category: "Session", + slash: { + name: "remote", + }, + onSelect: () => { + void input.onSelect() + }, + } +} + function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { return { externalOutputMode: "passthrough", @@ -208,6 +230,7 @@ export function tui(input: { Promise }) { }) const args = useArgs() + const remote = getRemoteSessionCommand({ + remote: args.remote, + onSelect: async () => { + await openRemoteSessionList({ + dialog, + sdk, + toast, + }) + }, + }) onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -378,6 +411,11 @@ function App(props: { onSnapshot?: () => Promise }) { }) local.model.set({ providerID, modelID }, { recent: true }) } + const sessions = args.remoteSessions + if (sessions?.length) { + dialog.replace(() => ) + return + } // Handle --session without --fork immediately (fork is handled in createEffect below) if (args.sessionID && !args.fork) { route.navigate({ @@ -454,6 +492,7 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, + ...(remote ? [remote] : []), ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? [ { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d1b..fd4141520839 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" import { existsSync } from "fs" +import { preflightRemote, resolveRemoteTarget } from "../remote" export const AttachCommand = cmd({ command: "attach ", @@ -20,6 +21,10 @@ export const AttachCommand = cmd({ type: "string", description: "directory to run in", }) + .option("workspace", { + type: "string", + description: "workspace ID to use on the remote server", + }) .option("continue", { alias: ["c"], describe: "continue the last session", @@ -66,6 +71,33 @@ export const AttachCommand = cmd({ const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() + const sdk = await preflightRemote({ + url: args.url, + directory, + workspaceID: args.workspace, + headers, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) + const target = await resolveRemoteTarget({ + sdk, + directory, + continue: args.continue, + sessionID: args.session, + fork: args.fork, + defer: args.continue && !args.session, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) + if (args.continue && target.baseID) { + UI.println( + UI.Style.TEXT_INFO_BOLD + "Continuing remote session" + UI.Style.TEXT_NORMAL, + target.title ?? target.baseID, + UI.Style.TEXT_DIM + `(${target.baseID})` + UI.Style.TEXT_NORMAL, + ) + } const config = await Instance.provide({ directory: directory && existsSync(directory) ? directory : process.cwd(), fn: () => TuiConfig.get(), @@ -74,9 +106,12 @@ export const AttachCommand = cmd({ url: args.url, config, args: { - continue: args.continue, - sessionID: args.session, + remote: true, + continue: target.remoteSessions || target.picked ? false : args.continue, + sessionID: target.picked ? target.baseID : args.session, + workspaceID: args.workspace, fork: args.fork, + remoteSessions: target.remoteSessions, }, directory, headers, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx new file mode 100644 index 000000000000..5601c716f1ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx @@ -0,0 +1,202 @@ +import { onMount } from "solid-js" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute, type RouteContext } from "@tui/context/route" +import { useSDK } from "@tui/context/sdk" +import { useToast } from "@tui/ui/toast" + +type Session = { + id: string + title?: string +} + +type Listed = Session & { + parentID?: string +} + +export async function listRemoteSessions(input: { + sdk: { + client: { + session: { + list(input: { roots: true }): Promise<{ data?: Listed[] }> + } + } + } +}) { + const result = await input.sdk.client.session.list({ roots: true }) + return (result.data ?? []).filter((item) => !item.parentID).map((item) => ({ id: item.id, title: item.title })) +} + +export async function openRemoteSessionList(input: { + dialog: Pick + sdk: { + client: { + session: { + list(input: { roots: true }): Promise<{ data?: Listed[] }> + } + } + } + toast: { + show(input: { message: string; variant?: "error" | "warning" | "info" | "success" }): void + } + fork?: boolean +}) { + const sessions = await listRemoteSessions({ sdk: input.sdk }) + if (!sessions.length) { + input.toast.show({ + message: "No remote sessions found", + variant: "info", + }) + return + } + input.dialog.replace(() => ) +} + +export function getRemoteBrowse(input: { root: Session; sessions: Session[]; fork?: boolean }) { + return { + title: input.fork ? "Fork from remote session" : "Continue remote session", + options: [input.root, ...input.sessions].map((item, idx) => ({ + title: item.title ?? item.id, + value: item.id, + footer: item.id, + description: input.fork ? (idx === 0 ? "Fork from root session" : "Fork from child session") : undefined, + })), + } +} + +type OpenInput = { + id: string + fork?: boolean + route: RouteContext + dialog: Pick + sdk: { + client: { + session: { + fork(input: { sessionID: string }): Promise<{ data?: { id?: string } }> + } + } + } + toast: { + show(input: { message: string; variant?: "error" | "warning" | "info" | "success" }): void + } +} + +type Input = OpenInput & { + title?: string + dialog: Pick + sdk: { + client: { + session: { + children?(input: { sessionID: string }): Promise<{ data?: Session[] }> + fork(input: { sessionID: string }): Promise<{ data?: { id?: string } }> + } + } + } +} + +export async function openRemoteSession(input: OpenInput) { + if (!input.fork) { + input.route.navigate({ + type: "session", + sessionID: input.id, + }) + input.dialog.clear() + return + } + + const result = await input.sdk.client.session.fork({ + sessionID: input.id, + }) + const id = result.data?.id + if (!id) { + input.toast.show({ + message: "Failed to fork session", + variant: "error", + }) + return + } + + input.route.navigate({ + type: "session", + sessionID: id, + }) + input.dialog.clear() +} + +export async function selectRemoteSession(input: Input) { + const result = await input.sdk.client.session.children?.({ + sessionID: input.id, + }) + if (result?.data?.length) { + input.dialog.replace(() => ( + + )) + return + } + + await openRemoteSession(input) +} + +function DialogRemoteSessionBrowse(props: { root: Session; sessions: Session[]; fork?: boolean }) { + const dialog = useDialog() + const route = useRoute() + const sdk = useSDK() + const toast = useToast() + const browse = getRemoteBrowse(props) + + return ( + { + void openRemoteSession({ + id: option.value, + fork: props.fork, + route, + dialog, + sdk, + toast, + }) + }} + /> + ) +} + +export function DialogRemoteSessionList(props: { sessions: Session[]; fork?: boolean }) { + const dialog = useDialog() + const route = useRoute() + const sdk = useSDK() + const toast = useToast() + + onMount(() => { + dialog.setSize("large") + }) + + return ( + ({ + title: item.title ?? item.id, + value: item.id, + footer: item.id, + }))} + skipFilter={false} + onSelect={(option) => { + void selectRemoteSession({ + id: option.value, + title: props.sessions.find((item) => item.id === option.value)?.title, + fork: props.fork, + route, + dialog, + sdk, + toast, + }) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 96563b884ede..78190b7d4a00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,6 +1,5 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" -import "opentui-spinner/solid" import path from "path" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" @@ -27,7 +26,6 @@ import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" import { formatDuration } from "@/util/format" -import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" @@ -35,6 +33,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { Spinner } from "../spinner" export type PromptProps = { sessionID?: string @@ -820,26 +819,6 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) - const spinnerDef = createMemo(() => { - const color = local.agent.color(local.agent.current().name) - return { - frames: createFrames({ - color, - style: "blocks", - inactiveFactor: 0.6, - // enableFading: false, - minAlpha: 0.3, - }), - color: createColors({ - color, - style: "blocks", - inactiveFactor: 0.6, - // enableFading: false, - minAlpha: 0.3, - }), - } - }) - return ( <> [⋯]}> - + diff --git a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx index 8dc54555043b..4563057a1b2e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx @@ -1,9 +1,8 @@ -import { Show } from "solid-js" +import { Show, createSignal, onCleanup, onMount } from "solid-js" import { useTheme } from "../context/theme" import { useKV } from "../context/kv" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" -import "opentui-spinner/solid" const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -11,10 +10,29 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { const { theme } = useTheme() const kv = useKV() const color = () => props.color ?? theme.textMuted + const [idx, setIdx] = createSignal(0) + + onMount(() => { + const id = setInterval(() => { + setIdx((v) => (v + 1) % frames.length) + }, 80) + onCleanup(() => clearInterval(id)) + }) + return ( - ⋯ {props.children}}> + + + + {props.children} + + + } + > - + {frames[idx()]} {props.children} diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 8a229ffaba69..9b971452adf2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -4,9 +4,15 @@ export interface Args { model?: string agent?: string prompt?: string + remote?: boolean + workspaceID?: string continue?: boolean sessionID?: string fork?: boolean + remoteSessions?: { + id: string + title?: string + }[] } export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index a0f1b3224911..f8bc258e3231 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -13,12 +13,13 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ init: (props: { url: string directory?: string + workspaceID?: string fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource }) => { const abort = new AbortController() - let workspaceID: string | undefined + let workspaceID = props.workspaceID let sse: AbortController | undefined function createSDK() { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f86d8d32af60..1ec5c82b0480 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1074,6 +1074,13 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + swarm_concurrency: z + .number() + .int() + .positive() + .max(10) + .optional() + .describe("Maximum number of concurrent sub-agents to spawn in a single swarm call"), }) .optional(), }) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..c2989ef268f6 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -40,8 +40,8 @@ export namespace Flag { export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export declare const OPENCODE_CLIENT: string - export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] - export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] + export declare const OPENCODE_SERVER_PASSWORD: string | undefined + export declare const OPENCODE_SERVER_USERNAME: string | undefined export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") // Experimental @@ -152,3 +152,19 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) + +Object.defineProperty(Flag, "OPENCODE_SERVER_PASSWORD", { + get() { + return process.env["OPENCODE_SERVER_PASSWORD"] + }, + enumerable: true, + configurable: false, +}) + +Object.defineProperty(Flag, "OPENCODE_SERVER_USERNAME", { + get() { + return process.env["OPENCODE_SERVER_USERNAME"] + }, + enumerable: true, + configurable: false, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 2da35ace1dd8..7b423a5fc35b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { TriggerCommand } from "./cli/cmd/trigger" import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" @@ -153,6 +154,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(TriggerCommand) .command(PluginCommand) .command(DbCommand) .fail((msg, err) => { diff --git a/packages/opencode/src/memory/index.ts b/packages/opencode/src/memory/index.ts new file mode 100644 index 000000000000..344ce5b000de --- /dev/null +++ b/packages/opencode/src/memory/index.ts @@ -0,0 +1,14 @@ +export { + MemoryID, + RuleID, + APIKeyID, + type PreferenceType, + Preference, + Rule, + APIKey, + MemoryRepoError, + MemoryServiceError, + type MemoryError, +} from "./schema" +export { MemoryRepo, type PreferenceRow, type RuleRow, type APIKeyRow } from "./repo" +export { MemoryPreferenceTable, MemoryRuleTable, MemoryAPIKeyTable } from "./memory.sql" diff --git a/packages/opencode/src/memory/memory.sql.ts b/packages/opencode/src/memory/memory.sql.ts new file mode 100644 index 000000000000..7240cf8ef7f1 --- /dev/null +++ b/packages/opencode/src/memory/memory.sql.ts @@ -0,0 +1,51 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../storage/schema.sql" +import { ProjectTable } from "../project/project.sql" + +export const MemoryPreferenceTable = sqliteTable( + "memory_preference", + { + id: text().primaryKey(), + key: text().notNull(), + value: text({ mode: "json" }).notNull(), + type: text().notNull(), + description: text(), + ...Timestamps, + }, + (table) => [index("memory_preference_key_idx").on(table.key)], +) + +export const MemoryRuleTable = sqliteTable( + "memory_rule", + { + id: text().primaryKey(), + project_id: text() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + pattern: text().notNull(), + rule: text().notNull(), + priority: integer().notNull().default(0), + enabled: integer({ mode: "boolean" }).notNull().default(true), + ...Timestamps, + }, + (table) => [ + index("memory_rule_project_idx").on(table.project_id), + index("memory_rule_pattern_idx").on(table.pattern), + ], +) + +export const MemoryAPIKeyTable = sqliteTable( + "memory_api_key", + { + id: text().primaryKey(), + provider: text().notNull(), + key_name: text().notNull(), + encrypted_value: text().notNull(), + description: text(), + ...Timestamps, + }, + (table) => [ + index("memory_api_key_provider_idx").on(table.provider), + index("memory_api_key_name_idx").on(table.key_name), + ], +) diff --git a/packages/opencode/src/memory/repo.ts b/packages/opencode/src/memory/repo.ts new file mode 100644 index 000000000000..2acf72c8979c --- /dev/null +++ b/packages/opencode/src/memory/repo.ts @@ -0,0 +1,234 @@ +import { eq } from "drizzle-orm" +import { Effect, Layer, Option, Schema, ServiceMap } from "effect" + +import { Database } from "@/storage/db" +import { MemoryPreferenceTable, MemoryRuleTable, MemoryAPIKeyTable } from "./memory.sql" +import { MemoryID, RuleID, APIKeyID, Preference, Rule, APIKey, MemoryRepoError, type PreferenceType } from "./schema" + +export type PreferenceRow = (typeof MemoryPreferenceTable)["$inferSelect"] +export type RuleRow = (typeof MemoryRuleTable)["$inferSelect"] +export type APIKeyRow = (typeof MemoryAPIKeyTable)["$inferSelect"] + +type DbClient = Parameters[0] extends (db: infer T) => unknown ? T : never +type DbTransactionCallback = Parameters>[0] + +export namespace MemoryRepo { + export interface Service { + readonly getPreference: (key: string) => Effect.Effect, MemoryRepoError> + readonly getPreferences: () => Effect.Effect + readonly setPreference: (input: { + id: MemoryID + key: string + value: unknown + type: PreferenceType + description?: string + }) => Effect.Effect + readonly removePreference: (key: string) => Effect.Effect + + readonly getRulesForProject: (projectID: string) => Effect.Effect + readonly setRule: (input: { + id: RuleID + projectID: string + pattern: string + rule: string + priority?: number + enabled?: boolean + }) => Effect.Effect + readonly removeRule: (id: RuleID) => Effect.Effect + + readonly getAPIKeys: () => Effect.Effect + readonly getAPIKey: (id: APIKeyID) => Effect.Effect, MemoryRepoError> + readonly setAPIKey: (input: { + id: APIKeyID + provider: string + keyName: string + encryptedValue: string + description?: string + }) => Effect.Effect + readonly removeAPIKey: (id: APIKeyID) => Effect.Effect + } +} + +export class MemoryRepo extends ServiceMap.Service()("@opencode/MemoryRepo") { + static readonly layer: Layer.Layer = Layer.effect( + MemoryRepo, + Effect.gen(function* () { + const decodePreference = Schema.decodeUnknownSync(Preference) + const decodeRule = Schema.decodeUnknownSync(Rule) + const decodeAPIKey = Schema.decodeUnknownSync(APIKey) + + const query = (f: DbTransactionCallback) => + Effect.try({ + try: () => Database.use(f), + catch: (cause) => new MemoryRepoError({ message: "Database operation failed", cause }), + }) + + const getPreference = Effect.fn("MemoryRepo.getPreference")((key: string) => + query((db) => db.select().from(MemoryPreferenceTable).where(eq(MemoryPreferenceTable.key, key)).get()).pipe( + Effect.map((row) => (row ? Option.some(decodePreference(row)) : Option.none())), + Effect.orElseSucceed(() => Option.none()), + ), + ) + + const getPreferences = Effect.fn("MemoryRepo.getPreferences")(() => + query((db) => db.select().from(MemoryPreferenceTable).all()).pipe( + Effect.map((rows) => rows.map((row) => decodePreference(row))), + ), + ) + + const setPreference = Effect.fn("MemoryRepo.setPreference")( + (input: { id: MemoryID; key: string; value: unknown; type: PreferenceType; description?: string }) => + query((db) => { + const now = Date.now() + db.insert(MemoryPreferenceTable) + .values({ + id: input.id as string, + key: input.key, + value: input.value, + type: input.type, + description: input.description ?? null, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: MemoryPreferenceTable.key, + set: { + value: input.value, + type: input.type, + description: input.description ?? null, + time_updated: now, + }, + }) + .run() + }).pipe(Effect.asVoid), + ) + + const removePreference = Effect.fn("MemoryRepo.removePreference")((key: string) => + query((db) => db.delete(MemoryPreferenceTable).where(eq(MemoryPreferenceTable.key, key)).run()).pipe( + Effect.asVoid, + ), + ) + + const getRulesForProject = Effect.fn("MemoryRepo.getRulesForProject")((projectID: string) => + query((db) => + db + .select() + .from(MemoryRuleTable) + .where(eq(MemoryRuleTable.project_id, projectID as string)) + .orderBy(MemoryRuleTable.priority) + .all(), + ).pipe(Effect.map((rows) => rows.map((row) => decodeRule(row)))), + ) + + const setRule = Effect.fn("MemoryRepo.setRule")( + (input: { + id: RuleID + projectID: string + pattern: string + rule: string + priority?: number + enabled?: boolean + }) => + query((db) => { + const now = Date.now() + db.insert(MemoryRuleTable) + .values({ + id: input.id as string, + project_id: input.projectID as string, + pattern: input.pattern, + rule: input.rule, + priority: input.priority ?? 0, + enabled: input.enabled ?? true, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: MemoryRuleTable.id, + set: { + pattern: input.pattern, + rule: input.rule, + priority: input.priority ?? 0, + enabled: input.enabled ?? true, + time_updated: now, + }, + }) + .run() + }).pipe(Effect.asVoid), + ) + + const removeRule = Effect.fn("MemoryRepo.removeRule")((id: RuleID) => + query((db) => + db + .delete(MemoryRuleTable) + .where(eq(MemoryRuleTable.id, id as string)) + .run(), + ).pipe(Effect.asVoid), + ) + + const getAPIKeys = Effect.fn("MemoryRepo.getAPIKeys")(() => + query((db) => db.select().from(MemoryAPIKeyTable).all()).pipe( + Effect.map((rows) => rows.map((row) => decodeAPIKey(row))), + ), + ) + + const getAPIKey = Effect.fn("MemoryRepo.getAPIKey")((id: APIKeyID) => + query((db) => + db + .select() + .from(MemoryAPIKeyTable) + .where(eq(MemoryAPIKeyTable.id, id as string)) + .get(), + ).pipe(Effect.map((row) => (row ? Option.some(row) : Option.none()))), + ) + + const setAPIKey = Effect.fn("MemoryRepo.setAPIKey")( + (input: { id: APIKeyID; provider: string; keyName: string; encryptedValue: string; description?: string }) => + query((db) => { + const now = Date.now() + db.insert(MemoryAPIKeyTable) + .values({ + id: input.id as string, + provider: input.provider, + key_name: input.keyName, + encrypted_value: input.encryptedValue, + description: input.description ?? null, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: MemoryAPIKeyTable.id, + set: { + encrypted_value: input.encryptedValue, + description: input.description ?? null, + time_updated: now, + }, + }) + .run() + }).pipe(Effect.asVoid), + ) + + const removeAPIKey = Effect.fn("MemoryRepo.removeAPIKey")((id: APIKeyID) => + query((db) => + db + .delete(MemoryAPIKeyTable) + .where(eq(MemoryAPIKeyTable.id, id as string)) + .run(), + ).pipe(Effect.asVoid), + ) + + return MemoryRepo.of({ + getPreference, + getPreferences, + setPreference, + removePreference, + getRulesForProject, + setRule, + removeRule, + getAPIKeys, + getAPIKey, + setAPIKey, + removeAPIKey, + }) + }), + ) +} diff --git a/packages/opencode/src/memory/schema.ts b/packages/opencode/src/memory/schema.ts new file mode 100644 index 000000000000..1f90a43c8af2 --- /dev/null +++ b/packages/opencode/src/memory/schema.ts @@ -0,0 +1,72 @@ +import { Schema } from "effect" + +import { withStatics } from "@/util/schema" + +export const MemoryID = Schema.String.pipe( + Schema.brand("MemoryID"), + withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })), +) +export type MemoryID = Schema.Schema.Type + +export const RuleID = Schema.String.pipe( + Schema.brand("RuleID"), + withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })), +) +export type RuleID = Schema.Schema.Type + +export const APIKeyID = Schema.String.pipe( + Schema.brand("APIKeyID"), + withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })), +) +export type APIKeyID = Schema.Schema.Type + +export type PreferenceType = "string" | "number" | "boolean" | "json" + +const PreferenceTypeSchema = Schema.Union([ + Schema.Literal("string"), + Schema.Literal("number"), + Schema.Literal("boolean"), + Schema.Literal("json"), +]) + +export class Preference extends Schema.Class("Preference")({ + id: MemoryID, + key: Schema.String, + value: Schema.Unknown, + type: PreferenceTypeSchema, + description: Schema.NullOr(Schema.String), + time_created: Schema.Number, + time_updated: Schema.Number, +}) {} + +export class Rule extends Schema.Class("Rule")({ + id: RuleID, + project_id: Schema.String, + pattern: Schema.String, + rule: Schema.String, + priority: Schema.Number, + enabled: Schema.Boolean, + time_created: Schema.Number, + time_updated: Schema.Number, +}) {} + +export class APIKey extends Schema.Class("APIKey")({ + id: APIKeyID, + provider: Schema.String, + key_name: Schema.String, + description: Schema.NullOr(Schema.String), + time_created: Schema.Number, + time_updated: Schema.Number, +}) {} + +export class MemoryRepoError extends Schema.TaggedErrorClass()("MemoryRepoError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class MemoryServiceError extends Schema.TaggedErrorClass()("MemoryServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export type MemoryError = MemoryRepoError | MemoryServiceError diff --git a/packages/opencode/src/permission/classifier.ts b/packages/opencode/src/permission/classifier.ts new file mode 100644 index 000000000000..d5f374035abe --- /dev/null +++ b/packages/opencode/src/permission/classifier.ts @@ -0,0 +1,190 @@ +import { Effect, Layer, ServiceMap } from "effect" +import { PermissionRouter } from "./router" +import type { Provider } from "@/provider/provider" +import { Log } from "@/util/log" +import { InstanceState } from "@/effect/instance-state" +import { generateText } from "ai" + +export namespace PermissionClassifier { + const log = Log.create({ service: "permission.classifier" }) + + export interface Interface { + readonly classify: ( + req: PermissionRouter.ToolCallRequest, + ) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/PermissionClassifier") {} + + type State = { + cache: Map + } + + export function createPrompt(req: PermissionRouter.ToolCallRequest, def: PermissionRouter.ToolDefinition): string { + const params = JSON.stringify(req.params, null, 2) + const context = req.context + + return `You are a security classifier for AI tool calls. Analyze the following tool call and assess its risk level. + +Tool ID: ${req.toolId} +Tool Description: ${def.description} +Tool Category: ${def.category} +Tool Flags: + - Read Only: ${def.flags.isReadOnly} + - Destructive: ${def.flags.isDestructive} + - Network: ${def.flags.isNetwork} + - System: ${def.flags.isSystem} + - File System: ${def.flags.isFileSystem} + +Default Risk Level: ${def.defaultRisk} + +Parameters: +\`\`\`json +${params} +\`\`\` + +Context: +- Working Directory: ${context.cwd} +- Previous Calls: ${context.previousCalls.join(", ") || "none"} +${context.userIntent ? `- User Intent: ${context.userIntent}` : ""} + +Analyze the risk considering: +1. Does this operation modify data (destructive)? +2. Does it access sensitive system resources? +3. Does it make network requests to external services? +4. Does it read/write files outside the working directory? +5. Are the parameters suspicious or potentially harmful? +6. Is the operation reversible? + +Respond with a JSON object containing: +{ + "riskLevel": "low" | "medium" | "high" | "critical", + "confidence": number between 0 and 1, + "reasoning": "detailed explanation of the risk assessment", + "suggestedAction": "allow" | "ask" | "deny" | "escalate", + "requiresHumanReview": boolean +} + +Default to higher caution for destructive operations.` + } + + export function parseResponse(text: string): PermissionRouter.ClassificationResult { + try { + const cleaned = text + .replace(/```json\s*/g, "") + .replace(/```\s*$/g, "") + .trim() + const json = JSON.parse(cleaned) + + return { + riskLevel: json.riskLevel ?? "medium", + confidence: Math.max(0, Math.min(1, json.confidence ?? 0.5)), + reasoning: json.reasoning ?? "No reasoning provided", + suggestedAction: json.suggestedAction ?? "ask", + requiresHumanReview: json.requiresHumanReview ?? true, + } + } catch (err) { + log.error("Failed to parse classifier response", { text, error: err }) + return { + riskLevel: "medium", + confidence: 0.5, + reasoning: "Failed to parse classifier response, defaulting to medium risk", + suggestedAction: "ask", + requiresHumanReview: true, + } + } + } + + export function determineAction( + classification: PermissionRouter.ClassificationResult, + def: PermissionRouter.ToolDefinition, + ): PermissionRouter.RoutingDecision["action"] { + const approvals = def.requiredApprovals + + if (classification.riskLevel === "critical") return "deny" + if (classification.requiresHumanReview) return "ask" + if (classification.confidence < 0.7) return "classify" + + if (approvals.includes("never")) return "deny" + if (approvals.includes("auto") && classification.riskLevel === "low" && classification.confidence > 0.9) { + return "allow" + } + if (approvals.includes("classifier")) { + if (classification.suggestedAction === "allow" && classification.confidence > 0.8) return "allow" + if (classification.suggestedAction === "deny") return "deny" + return "ask" + } + + return "ask" + } + + export function getCacheKey(req: PermissionRouter.ToolCallRequest): string { + return `${req.toolId}:${JSON.stringify(req.params)}` + } + + export function layer(model: Provider.Model) { + return Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("PermissionClassifier.state")(function* () { + return { cache: new Map() } + }), + ) + + const classify = Effect.fn("PermissionClassifier.classify")(function* (req: PermissionRouter.ToolCallRequest) { + const key = getCacheKey(req) + const s = yield* InstanceState.get(state) + const cached = s.cache.get(key) + if (cached) { + log.info("Using cached classification", { toolId: req.toolId, key }) + return cached + } + + const { getLanguage } = yield* Effect.promise(() => import("@/provider/provider").then((m) => m.Provider)) + const language = yield* Effect.promise(() => getLanguage(model)) + + const def = PermissionRouter.BuiltinToolClassifications[req.toolId] + if (!def) { + log.warn("Unknown tool, using default classification", { toolId: req.toolId }) + const result: PermissionRouter.ClassificationResult = { + riskLevel: "medium", + confidence: 0.5, + reasoning: "Unknown tool, defaulting to medium risk", + suggestedAction: "ask", + requiresHumanReview: true, + } + s.cache.set(key, result) + return result + } + + const prompt = createPrompt(req, def) + log.info("Classifying tool call", { toolId: req.toolId, defaultRisk: def.defaultRisk }) + + const response = yield* Effect.promise(() => + generateText({ + model: language, + prompt, + temperature: 0.1, + maxOutputTokens: 500, + }), + ) + + const result = parseResponse(response.text) + s.cache.set(key, result) + + log.info("Classification complete", { + toolId: req.toolId, + riskLevel: result.riskLevel, + confidence: result.confidence, + suggestedAction: result.suggestedAction, + }) + + return result + }) + + return Service.of({ classify }) + }), + ) + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610a5..d42e71395e1a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -320,3 +320,7 @@ export namespace Permission { return runPromise((s) => s.list()) } } + +export { PermissionRouter } from "./router" +export { PermissionClassifier } from "./classifier" +export { PermissionRouterService } from "./router-service" diff --git a/packages/opencode/src/permission/router-service.ts b/packages/opencode/src/permission/router-service.ts new file mode 100644 index 000000000000..11f966b3abe4 --- /dev/null +++ b/packages/opencode/src/permission/router-service.ts @@ -0,0 +1,83 @@ +import { Effect, Layer } from "effect" +import { PermissionRouter } from "./router" +import { PermissionClassifier } from "./classifier" +import { Log } from "@/util/log" +import { InstanceState } from "@/effect/instance-state" +import type { Provider } from "@/provider/provider" + +export namespace PermissionRouterService { + const log = Log.create({ service: "permission.router.service" }) + + type State = { + tools: Map + } + + function validateParams(def: PermissionRouter.ToolDefinition, params: Record): boolean { + try { + def.parameters.parse(params) + return true + } catch { + return false + } + } + + export const layer = (model: Provider.Model) => + Layer.effect( + PermissionRouter.Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("PermissionRouterService.state")(function* () { + const tools = new Map() + + for (const [id, def] of Object.entries(PermissionRouter.BuiltinToolClassifications)) { + tools.set(id, def) + } + + return { tools } + }), + ) + + const s = yield* InstanceState.get(state) + + return PermissionRouter.Service.of({ + register: (def) => Effect.sync(() => { + s.tools.set(def.id, def) + log.info("Registered tool", { toolId: def.id }) + }), + + route: (req) => Effect.sync(() => { + log.info("Routing", { toolId: req.toolId }) + const def = s.tools.get(req.toolId) + const risk = def?.defaultRisk ?? "medium" + return { + toolId: req.toolId, + action: (risk === "low" ? "allow" : "ask") as "allow" | "deny" | "ask" | "classify", + riskLevel: risk, + reasoning: "Routed based on tool classification", + } + }), + + validate: (req) => Effect.sync(() => { + const def = s.tools.get(req.toolId) + if (!def) throw new Error("Tool not found") + if (!validateParams(def, req.params)) throw new Error("Invalid params") + }), + + classify: (req) => Effect.sync(() => { + const def = s.tools.get(req.toolId) + return { + riskLevel: def?.defaultRisk ?? "medium", + confidence: 0.8, + reasoning: "Based on tool definition", + suggestedAction: (def?.defaultRisk === "low" ? "allow" : "ask") as "allow" | "ask" | "deny" | "escalate", + requiresHumanReview: def?.defaultRisk !== "low", + } + }), + + getToolDef: (toolId) => Effect.sync(() => s.tools.get(toolId)), + + listTools: () => Effect.sync(() => Array.from(s.tools.values())), + }) + }), + ) +} diff --git a/packages/opencode/src/permission/router.ts b/packages/opencode/src/permission/router.ts new file mode 100644 index 000000000000..d0b42d6caa47 --- /dev/null +++ b/packages/opencode/src/permission/router.ts @@ -0,0 +1,181 @@ +import z from "zod" +import { Effect, Schema, ServiceMap } from "effect" +import { Log } from "@/util/log" + +export namespace PermissionRouter { + const log = Log.create({ service: "permission.router" }) + + export const RiskLevel = z.enum(["low", "medium", "high", "critical"]).meta({ + ref: "PermissionRiskLevel", + }) + export type RiskLevel = z.infer + + export const ToolFlags = z + .object({ + isReadOnly: z.boolean().default(false), + isDestructive: z.boolean().default(false), + isNetwork: z.boolean().default(false), + isSystem: z.boolean().default(false), + isFileSystem: z.boolean().default(false), + }) + .meta({ ref: "PermissionToolFlags" }) + export type ToolFlags = z.infer + + export const ToolDefinition = z + .object({ + id: z.string().min(1).max(64), + description: z.string().min(1).max(1000), + category: z.enum(["read", "write", "execute", "network", "system", "other"]), + flags: ToolFlags, + defaultRisk: RiskLevel, + parameters: z.custom(), + requiredApprovals: z.array(z.enum(["user", "auto", "classifier", "never"])).default(["user"]), + }) + .meta({ ref: "PermissionToolDefinition" }) + export type ToolDefinition = z.infer + + export const ToolCallRequest = z + .object({ + toolId: z.string(), + params: z.record(z.string(), z.any()), + sessionID: z.string(), + context: z + .object({ + cwd: z.string(), + previousCalls: z.array(z.string()).default([]), + userIntent: z.string().optional(), + }) + .default({ cwd: ".", previousCalls: [] }), + }) + .meta({ ref: "PermissionToolCallRequest" }) + export type ToolCallRequest = z.infer + + export const ClassificationResult = z + .object({ + riskLevel: RiskLevel, + confidence: z.number().min(0).max(1), + reasoning: z.string(), + suggestedAction: z.enum(["allow", "ask", "deny", "escalate"]), + requiresHumanReview: z.boolean(), + }) + .meta({ ref: "PermissionClassificationResult" }) + export type ClassificationResult = z.infer + + export const RoutingDecision = z + .object({ + toolId: z.string(), + action: z.enum(["allow", "deny", "ask", "classify"]), + riskLevel: RiskLevel, + reasoning: z.string(), + classification: ClassificationResult.optional(), + }) + .meta({ ref: "PermissionRoutingDecision" }) + export type RoutingDecision = z.infer + + export class ValidationError extends Schema.TaggedErrorClass()("PermissionValidationError", { + toolId: Schema.String, + field: Schema.String, + message: Schema.String, + }) { + override get message(): string { + return `Validation failed for tool '${this.toolId}' on field '${this.field}': ${this.message}` + } + } + + export class RoutingError extends Schema.TaggedErrorClass()("PermissionRoutingError", { + toolId: Schema.String, + reason: Schema.String, + }) { + override get message(): string { + return `Routing failed for tool '${this.toolId}': ${this.reason}` + } + } + + export const BuiltinToolClassifications: Record = { + read: { + id: "read", + description: "Read file contents", + category: "read", + flags: { isReadOnly: true, isDestructive: false, isNetwork: false, isSystem: false, isFileSystem: true }, + defaultRisk: "low", + parameters: z.object({ filePath: z.string(), offset: z.number().optional(), limit: z.number().optional() }), + requiredApprovals: ["auto"], + }, + bash: { + id: "bash", + description: "Execute shell commands", + category: "execute", + flags: { isReadOnly: false, isDestructive: true, isNetwork: false, isSystem: true, isFileSystem: true }, + defaultRisk: "high", + parameters: z.object({ command: z.string(), timeout: z.number().optional(), workdir: z.string().optional() }), + requiredApprovals: ["user"], + }, + write: { + id: "write", + description: "Write file contents", + category: "write", + flags: { isReadOnly: false, isDestructive: true, isNetwork: false, isSystem: false, isFileSystem: true }, + defaultRisk: "medium", + parameters: z.object({ filePath: z.string(), content: z.string() }), + requiredApprovals: ["user"], + }, + edit: { + id: "edit", + description: "Edit file contents", + category: "write", + flags: { isReadOnly: false, isDestructive: true, isNetwork: false, isSystem: false, isFileSystem: true }, + defaultRisk: "medium", + parameters: z.object({ filePath: z.string(), oldString: z.string(), newString: z.string() }), + requiredApprovals: ["user"], + }, + glob: { + id: "glob", + description: "Find files matching pattern", + category: "read", + flags: { isReadOnly: true, isDestructive: false, isNetwork: false, isSystem: false, isFileSystem: true }, + defaultRisk: "low", + parameters: z.object({ pattern: z.string(), path: z.string().optional() }), + requiredApprovals: ["auto"], + }, + grep: { + id: "grep", + description: "Search file contents", + category: "read", + flags: { isReadOnly: true, isDestructive: false, isNetwork: false, isSystem: false, isFileSystem: true }, + defaultRisk: "low", + parameters: z.object({ pattern: z.string(), path: z.string().optional(), include: z.string().optional() }), + requiredApprovals: ["auto"], + }, + webfetch: { + id: "webfetch", + description: "Fetch web content", + category: "network", + flags: { isReadOnly: true, isDestructive: false, isNetwork: true, isSystem: false, isFileSystem: false }, + defaultRisk: "medium", + parameters: z.object({ url: z.string() }), + requiredApprovals: ["classifier"], + }, + websearch: { + id: "websearch", + description: "Search the web", + category: "network", + flags: { isReadOnly: true, isDestructive: false, isNetwork: true, isSystem: false, isFileSystem: false }, + defaultRisk: "medium", + parameters: z.object({ query: z.string() }), + requiredApprovals: ["classifier"], + }, + } + + export type Error = ValidationError | RoutingError + + export interface Interface { + readonly register: (def: ToolDefinition) => Effect.Effect + readonly route: (req: ToolCallRequest) => Effect.Effect + readonly validate: (req: ToolCallRequest) => Effect.Effect + readonly classify: (req: ToolCallRequest) => Effect.Effect + readonly getToolDef: (toolId: string) => Effect.Effect + readonly listTools: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/PermissionRouter") {} +} diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 4bb6efaf9b05..1647ce427044 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -25,6 +25,7 @@ import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" import { EventRoutes } from "./routes/event" +import { TriggerRoutes } from "./routes/trigger" import { errorHandler } from "./middleware" const log = Log.create({ service: "server" }) @@ -51,6 +52,7 @@ export const InstanceRoutes = (app?: Hono) => .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) + .route("/trigger", TriggerRoutes()) .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts new file mode 100644 index 000000000000..85524abec263 --- /dev/null +++ b/packages/opencode/src/server/routes/trigger.ts @@ -0,0 +1,217 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Trigger } from "@/trigger" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +const Params = z.object({ id: z.string() }) +const AuthError = z.object({ message: z.string() }).meta({ ref: "UnauthorizedError" }) + +export const TriggerRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List triggers", + description: "List lightweight scheduled triggers for the current instance.", + operationId: "trigger.list", + responses: { + 200: { + description: "Triggers", + content: { + "application/json": { + schema: resolver(Trigger.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Trigger.list()) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create trigger", + description: "Register a lightweight scheduled trigger for the current instance.", + operationId: "trigger.create", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Trigger.CreateInput), + async (c) => { + return c.json(await Trigger.create(c.req.valid("json"))) + }, + ) + .get( + "/:id", + describeRoute({ + summary: "Get trigger", + description: "Get the current state for a lightweight scheduled trigger.", + operationId: "trigger.get", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.get(c.req.valid("param").id)) + }, + ) + .post( + "/:id/fire", + describeRoute({ + summary: "Fire trigger", + description: "Invoke a lightweight scheduled trigger immediately.", + operationId: "trigger.fire", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(AuthError), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.fire(c.req.valid("param").id, "manual")) + }, + ) + .post( + "/:id/fire/webhook", + describeRoute({ + summary: "Fire trigger webhook", + description: "Invoke a lightweight scheduled trigger immediately through an authenticated webhook endpoint.", + operationId: "trigger.fire_webhook", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(AuthError), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + const id = c.req.valid("param").id + const item = await Trigger.get(id) + if (item.webhook_secret && c.req.header("X-Trigger-Secret") !== item.webhook_secret) { + return c.json({ message: "Unauthorized" }, 401) + } + return c.json(await Trigger.fire(id, "webhook")) + }, + ) + .post( + "/:id/enable", + describeRoute({ + summary: "Enable trigger", + description: "Enable a lightweight scheduled trigger.", + operationId: "trigger.enable", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.enable(c.req.valid("param").id)) + }, + ) + .post( + "/:id/disable", + describeRoute({ + summary: "Disable trigger", + description: "Disable a lightweight scheduled trigger.", + operationId: "trigger.disable", + responses: { + 200: { + description: "Trigger", + content: { + "application/json": { + schema: resolver(Trigger.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.disable(c.req.valid("param").id)) + }, + ) + .delete( + "/:id", + describeRoute({ + summary: "Delete trigger", + description: "Delete a lightweight scheduled trigger.", + operationId: "trigger.delete", + responses: { + 200: { + description: "Trigger deleted", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + await Trigger.remove(c.req.valid("param").id) + return c.json({ success: true as const }) + }, + ), +) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 999a37b1226d..180dc96d13de 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -36,6 +36,49 @@ export namespace SessionCompaction { export const PRUNE_PROTECT = 40_000 const PRUNE_PROTECTED_TOOLS = ["skill"] + export function prunePlan(input: { + messages: MessageV2.WithParts[] + protect?: number + minimum?: number + turns?: number + protected?: readonly string[] + }) { + const protect = input.protect ?? PRUNE_PROTECT + const minimum = input.minimum ?? PRUNE_MINIMUM + const turnsToKeep = input.turns ?? 2 + const protectedTools = input.protected ?? PRUNE_PROTECTED_TOOLS + let total = 0 + let pruned = 0 + let turns = 0 + const parts: MessageV2.ToolPart[] = [] + + loop: for (let msgIndex = input.messages.length - 1; msgIndex >= 0; msgIndex--) { + const msg = input.messages[msgIndex] + if (msg.info.role === "user") turns++ + if (turns < turnsToKeep) continue + if (msg.info.role === "assistant" && msg.info.summary) break loop + for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { + const part = msg.parts[partIndex] + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (protectedTools.includes(part.tool)) continue + if (part.state.time.compacted) break loop + const estimate = Token.estimate(part.state.output) + total += estimate + if (total <= protect) continue + pruned += estimate + parts.push(part) + } + } + + return { + total, + pruned, + parts, + shouldPrune: pruned > minimum, + } + } + export interface Interface { readonly isOverflow: (input: { tokens: MessageV2.Assistant["tokens"] @@ -100,42 +143,15 @@ export namespace SessionCompaction { .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined))) if (!msgs) return - let total = 0 - let pruned = 0 - const toPrune: MessageV2.ToolPart[] = [] - let turns = 0 - - loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { - const msg = msgs[msgIndex] - if (msg.info.role === "user") turns++ - if (turns < 2) continue - if (msg.info.role === "assistant" && msg.info.summary) break loop - for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { - const part = msg.parts[partIndex] - if (part.type === "tool") - if (part.state.status === "completed") { - if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue - if (part.state.time.compacted) break loop - const estimate = Token.estimate(part.state.output) - total += estimate - if (total > PRUNE_PROTECT) { - pruned += estimate - toPrune.push(part) - } - } - } - } - - log.info("found", { pruned, total }) - if (pruned > PRUNE_MINIMUM) { - for (const part of toPrune) { - if (part.state.status === "completed") { - part.state.time.compacted = Date.now() - yield* session.updatePart(part) - } - } - log.info("pruned", { count: toPrune.length }) + const plan = prunePlan({ messages: msgs }) + log.info("found", { pruned: plan.pruned, total: plan.total }) + if (!plan.shouldPrune) return + for (const part of plan.parts) { + if (part.state.status !== "completed") continue + part.state.time.compacted = Date.now() + yield* session.updatePart(part) } + log.info("pruned", { count: plan.parts.length }) }) const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5ed5acafaf54..2debd4410c8a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -184,6 +184,125 @@ export namespace Session { }) export type GlobalInfo = z.output + type GlobalRow = SessionRow & { + project_name: string | null + project_worktree: string | null + } + + function globalRows( + file: string, + input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }, + ) { + const where: string[] = [] + const args: Array = [] + + if (input?.directory) { + where.push("s.directory = ?") + args.push(input.directory) + } + if (input?.roots) where.push("s.parent_id is null") + if (input?.start) { + where.push("s.time_updated >= ?") + args.push(input.start) + } + if (input?.cursor) { + where.push("s.time_updated < ?") + args.push(input.cursor) + } + if (input?.search) { + where.push("s.title like ?") + args.push(`%${input.search}%`) + } + if (!input?.archived) where.push("s.time_archived is null") + + const sql = [ + "select s.*, p.name as project_name, p.worktree as project_worktree", + "from session s", + "left join project p on p.id = s.project_id", + where.length > 0 ? `where ${where.join(" and ")}` : "", + "order by s.time_updated desc, s.id desc", + "limit ?", + ] + .filter(Boolean) + .join(" ") + + try { + return Database.read(file, (db) => db.query(sql).all(...args, input?.limit ?? 100) as GlobalRow[]) + } catch { + return [] + } + } + + function currentRows(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }) { + const conditions: SQL[] = [] + + if (input?.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input?.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input?.start) conditions.push(gte(SessionTable.time_updated, input.start)) + if (input?.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) + if (input?.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (!input?.archived) conditions.push(isNull(SessionTable.time_archived)) + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(input?.limit ?? 100) + .all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + return rows.map((row) => { + const project = projects.get(row.project_id) + return { + ...row, + project_name: project?.name ?? null, + project_worktree: project?.worktree ?? null, + } satisfies GlobalRow + }) + } + export const Event = { Created: SyncEvent.define({ type: "session.created", @@ -793,63 +912,33 @@ export namespace Session { limit?: number archived?: boolean }) { - const conditions: SQL[] = [] - - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.cursor) { - conditions.push(lt(SessionTable.time_updated, input.cursor)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - if (!input?.archived) { - conditions.push(isNull(SessionTable.time_archived)) - } - const limit = input?.limit ?? 100 - const rows = Database.use((db) => { - const query = - conditions.length > 0 - ? db - .select() - .from(SessionTable) - .where(and(...conditions)) - : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + const seen = new Set() + const rows = [ + ...currentRows({ ...input, limit }), + ...Database.paths() + .filter((file) => file !== Database.Path) + .flatMap((file) => globalRows(file, { ...input, limit })), + ].toSorted((a, b) => { + if (a.time_updated !== b.time_updated) return b.time_updated - a.time_updated + return b.id.localeCompare(a.id) }) - const ids = [...new Set(rows.map((row) => row.project_id))] - const projects = new Map() - - if (ids.length > 0) { - const items = Database.use((db) => - db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) - .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) - .all(), - ) - for (const item of items) { - projects.set(item.id, { - id: item.id, - name: item.name ?? undefined, - worktree: item.worktree, - }) - } - } - for (const row of rows) { - const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } + if (seen.has(row.id)) continue + seen.add(row.id) + if (seen.size > limit) break + yield { + ...fromRow(row), + project: row.project_worktree + ? { + id: row.project_id, + name: row.project_name ?? undefined, + worktree: row.project_worktree, + } + : null, + } } } diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 189a596873a3..0a6e9c948655 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -3,6 +3,7 @@ import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" +import type { Trigger } from "../trigger" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" @@ -10,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type TriggerLast = NonNullable export const SessionTable = sqliteTable( "session", @@ -101,3 +103,26 @@ export const PermissionTable = sqliteTable("permission", { ...Timestamps, data: text({ mode: "json" }).notNull().$type(), }) + +export const TriggerTable = sqliteTable( + "trigger", + { + id: text().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + schedule: text({ mode: "json" }).notNull().$type(), + action: text({ mode: "json" }).$type(), + webhook_secret: text(), + enabled: integer({ mode: "boolean" }).notNull(), + runs: integer().notNull(), + ...Timestamps, + last_source: text().$type(), + last_status: text().$type(), + last_error: text(), + time_last: integer(), + time_next: integer().notNull(), + }, + (table) => [index("trigger_project_idx").on(table.project_id)], +) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 4cb0dbc3e184..907672fe9949 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -7,6 +7,7 @@ import { lazy } from "../util/lazy" import { Global } from "../global" import { Log } from "../util/log" import { NamedError } from "@opencode-ai/util/error" +import { Database as Sqlite } from "bun:sqlite" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" @@ -28,6 +29,8 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export namespace Database { + const pattern = /^opencode(?:-[A-Za-z0-9._-]+)?\.db$/ + export function getChannelPath() { if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") @@ -43,6 +46,40 @@ export namespace Database { return getChannelPath() }) + export function paths() { + if (Flag.OPENCODE_DB) return [Path] + + const seen = new Set() + const result: string[] = [] + const push = (file: string) => { + if (seen.has(file)) return + if (!existsSync(file)) return + seen.add(file) + result.push(file) + } + + push(Path) + + try { + for (const item of readdirSync(Global.Path.data, { withFileTypes: true })) { + if (!item.isFile()) continue + if (!pattern.test(item.name)) continue + push(path.join(Global.Path.data, item.name)) + } + } catch {} + + return result.length > 0 ? result : [Path] + } + + export function read(file: string, fn: (db: Sqlite) => T) { + const db = new Sqlite(file, { readonly: true }) + try { + return fn(db) + } finally { + db.close() + } + } + export type Transaction = SQLiteTransaction<"sync", void> type Client = SQLiteBunDatabase diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee62201..8494263b0f3e 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql" export { ProjectTable } from "../project/project.sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" +export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable, TriggerTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" export { WorkspaceTable } from "../control-plane/workspace.sql" diff --git a/packages/opencode/src/tool/brief.ts b/packages/opencode/src/tool/brief.ts new file mode 100644 index 000000000000..7a63cdeed97f --- /dev/null +++ b/packages/opencode/src/tool/brief.ts @@ -0,0 +1,36 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" +import { Agent } from "../agent/agent" +import { Provider } from "../provider/provider" +import { SessionCompaction } from "../session/compaction" +import { SessionPrompt } from "../session/prompt" +import type { SessionID } from "../session/schema" + +async function state(sessionID: SessionID) { + const msgs = await Session.messages({ sessionID }) + const user = msgs.findLast((item) => item.info.role === "user") + if (user && user.info.role === "user") return { agent: user.info.agent, model: user.info.model } + const [agent, model] = await Promise.all([Agent.defaultAgent(), Provider.defaultModel()]) + return { agent, model } +} + +export const BriefTool = Tool.define("brief", { + description: "Create a compact briefing of the current session using the active session context and model settings.", + parameters: z.object({}), + async execute(_input, ctx) { + const next = await state(ctx.sessionID) + await SessionCompaction.create({ + sessionID: ctx.sessionID, + agent: next.agent, + model: next.model, + auto: false, + }) + await SessionPrompt.loop({ sessionID: ctx.sessionID }) + return { + title: "Brief complete", + output: "Created a concise session brief.", + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/browser.ts b/packages/opencode/src/tool/browser.ts new file mode 100644 index 000000000000..d93a7bcbe9ee --- /dev/null +++ b/packages/opencode/src/tool/browser.ts @@ -0,0 +1,135 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./browser.txt" +import { chromium, type Browser, type Page } from "playwright" +import { abortAfterAny } from "../util/abort" + +const DEFAULT_TIMEOUT_MS = 30 * 1000 +const MAX_TIMEOUT_MS = 120 * 1000 + +export const BrowserTool = Tool.define("browser", { + description: DESCRIPTION, + parameters: z.object({ + action: z + .enum(["navigate", "execute", "read"]) + .describe( + "The browser action to perform: navigate (go to URL), execute (run JavaScript), or read (get DOM content)", + ), + url: z.string().describe("The URL to navigate to (required for navigate action)").optional(), + script: z.string().describe("The JavaScript code to execute (required for execute action)").optional(), + selector: z + .string() + .describe("CSS selector to target specific elements (optional for read action, reads full page if omitted)") + .optional(), + waitFor: z + .enum(["load", "domcontentloaded", "networkidle"]) + .default("load") + .describe("When to consider navigation complete (load, domcontentloaded, networkidle)"), + timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), + }), + async execute(params, ctx) { + const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000, MAX_TIMEOUT_MS) + + if (params.action === "navigate" && !params.url) { + throw new Error("URL is required for navigate action") + } + if (params.action === "execute" && !params.script) { + throw new Error("Script is required for execute action") + } + + if (params.url && !params.url.startsWith("http://") && !params.url.startsWith("https://")) { + throw new Error("URL must start with http:// or https://") + } + + await ctx.ask({ + permission: "browser", + patterns: params.url ? [params.url] : ["*"], + always: ["*"], + metadata: { + action: params.action, + url: params.url, + hasScript: !!params.script, + selector: params.selector, + }, + }) + + const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort) + + let browser: Browser | undefined + let page: Page | undefined + + try { + browser = await chromium.launch({ headless: true }) + page = await browser.newPage() + await page.setViewportSize({ width: 1280, height: 720 }) + + let result: { title: string; output: string; metadata: Record } + + switch (params.action) { + case "navigate": { + const response = await page.goto(params.url!, { + waitUntil: params.waitFor, + timeout, + }) + + signal.throwIfAborted() + + const status = response?.status() ?? 0 + const title = await page.title().catch(() => "") + const url = page.url() + + result = { + title: `Navigated to ${url}`, + output: `Successfully navigated to ${url}\nStatus: ${status}\nTitle: ${title}`, + metadata: { status, url, title }, + } + break + } + + case "execute": { + const execResult = await page.evaluate(params.script!) + signal.throwIfAborted() + + const output = typeof execResult === "object" ? JSON.stringify(execResult, null, 2) : String(execResult) + + result = { + title: "JavaScript executed", + output, + metadata: { resultType: typeof execResult }, + } + break + } + + case "read": { + let content: string + + if (params.selector) { + const elements = await page.locator(params.selector).all() + const texts = await Promise.all(elements.map((el) => el.textContent().catch(() => ""))) + content = texts.join("\n") + } else { + content = await page.content() + } + + signal.throwIfAborted() + + result = { + title: params.selector ? `Read DOM elements matching "${params.selector}"` : "Read full page DOM", + output: content, + metadata: { selector: params.selector, length: content.length }, + } + break + } + + default: + throw new Error(`Unknown action: ${params.action}`) + } + + clearTimeout() + return result + } finally { + if (page) await page.close().catch(() => {}) + if (browser) await browser.close().catch(() => {}) + } + }, +}) diff --git a/packages/opencode/src/tool/browser.txt b/packages/opencode/src/tool/browser.txt new file mode 100644 index 000000000000..ecb559919873 --- /dev/null +++ b/packages/opencode/src/tool/browser.txt @@ -0,0 +1,16 @@ +- Automates browser interactions using Playwright headless Chromium +- Supports three actions: navigate (load URLs), execute (run JavaScript), read (extract DOM content) +- Navigates to web pages and waits for specified load state (load, domcontentloaded, networkidle) +- Executes JavaScript in page context and returns results as JSON or string +- Reads full page HTML or extracts text from specific elements via CSS selectors +- Use this tool when you need to interact with dynamic web content, SPAs, or pages requiring JavaScript execution +- Use instead of webfetch when you need to execute JavaScript or interact with page elements + +Usage notes: + - URL must be fully-formed with http:// or https:// + - Navigate action requires url parameter + - Execute action requires script parameter containing JavaScript code + - Read action can target specific elements with selector or read full page without it + - Timeout defaults to 30 seconds, maximum 120 seconds + - Browser runs headless with 1280x720 viewport + - Always requests permission before accessing URLs diff --git a/packages/opencode/src/tool/desktop.runtime.ts b/packages/opencode/src/tool/desktop.runtime.ts new file mode 100644 index 000000000000..619d5e4b7518 --- /dev/null +++ b/packages/opencode/src/tool/desktop.runtime.ts @@ -0,0 +1,4 @@ +import * as nut from "@nut-tree-fork/nut-js" + +export default nut +export * from "@nut-tree-fork/nut-js" diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts new file mode 100644 index 000000000000..06151028b54a --- /dev/null +++ b/packages/opencode/src/tool/desktop.ts @@ -0,0 +1,257 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./desktop.txt" +import { Log } from "../util/log" +import path from "path" +import fsSync from "fs" +import { pathToFileURL } from "url" +import { imageToJimp } from "@nut-tree-fork/shared" + +const log = Log.create({ service: "desktop-tool" }) + +const modifier = z.enum(["cmd", "command", "super", "ctrl", "control", "alt", "option", "meta", "shift"]) + +const alias = { + cmd: "LeftSuper", + command: "LeftSuper", + super: "LeftSuper", + ctrl: "LeftControl", + control: "LeftControl", + alt: "LeftAlt", + option: "LeftAlt", + meta: "LeftAlt", + shift: "LeftShift", + space: "Space", + enter: "Enter", + return: "Enter", + esc: "Escape", + escape: "Escape", + tab: "Tab", + up: "Up", + down: "Down", + left: "Left", + right: "Right", +} as const + +let nutCache: any = undefined +let nutFailed = false +let nutError: Error | undefined = undefined + +export function resolveNutJsImportSpecifier(moduleURL = import.meta.url, execPath = process.execPath) { + if (!moduleURL.includes("/$bunfs/root/")) return "@nut-tree-fork/nut-js" + + const realExecPath = fsSync.realpathSync(execPath) + const helperPath = path.join(path.dirname(realExecPath), "desktop.runtime.mjs") + return pathToFileURL(helperPath).href +} + +async function loadNutJs() { + if (nutCache) return nutCache + if (nutFailed) { + const details = nutError ? `: ${nutError.message}` : "." + throw new Error( + `Desktop automation library not available${details} Please ensure @nut-tree-fork/nut-js is installed.`, + ) + } + try { + nutCache = await import(resolveNutJsImportSpecifier()) + return nutCache + } catch (error) { + nutFailed = true + nutError = error instanceof Error ? error : new Error(String(error)) + log.warn("@nut-tree-fork/nut-js not available, desktop tool disabled", { + error: nutError.message, + code: (error as any)?.code, + platform: process.platform, + arch: process.arch, + }) + throw new Error( + `Desktop automation library not available: ${nutError.message}. Please ensure @nut-tree-fork/nut-js is installed.`, + ) + } +} + +function key(nut: any, input: string) { + const name = input.trim().toLowerCase() + if (!name) throw new Error("key_click action requires key parameter") + + const value = alias[name as keyof typeof alias] + if (value) return nut.Key[value] + if (name.length === 1) return name + + throw new Error(`Unsupported key: ${input}`) +} + +export const DesktopTool = Tool.define("desktop", async () => { + return { + description: DESCRIPTION, + parameters: z.object({ + action: z + .enum(["screenshot", "mouse_move", "mouse_click", "type", "key_click"]) + .describe("The desktop automation action to perform"), + region: z + .object({ + x: z.number().describe("X coordinate of top-left corner"), + y: z.number().describe("Y coordinate of top-left corner"), + width: z.number().describe("Width of region in pixels"), + height: z.number().describe("Height of region in pixels"), + }) + .optional() + .describe("Optional region for partial screenshot (full screen if omitted)"), + x: z.number().optional().describe("X coordinate for mouse movement (absolute position)"), + y: z.number().optional().describe("Y coordinate for mouse movement (absolute position)"), + button: z.enum(["left", "right", "middle"]).optional().describe("Mouse button to click (default: left)"), + clickAt: z + .object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + }) + .optional() + .describe("Optional coordinates to move to before clicking"), + doubleClick: z.boolean().optional().describe("Perform double-click instead of single click"), + text: z.string().optional().describe("Text to type"), + key: z.string().optional().describe("Key to click or press"), + modifiers: z.array(modifier).optional().describe("Modifier keys to hold while clicking the key"), + }), + async execute(params, ctx) { + const nut = await loadNutJs() + + switch (params.action) { + case "screenshot": { + log.info("Taking screenshot", { region: params.region }) + + let image: any + let width: number + let height: number + + if (params.region) { + const region = new nut.Region(params.region.x, params.region.y, params.region.width, params.region.height) + image = await nut.screen.grabRegion(region) + width = params.region.width + height = params.region.height + } else { + image = await nut.screen.grab() + width = await nut.screen.width() + height = await nut.screen.height() + } + + const imageBuffer = await imageToJimp(image).getBufferAsync("image/png") + const base64Data = imageBuffer.toString("base64") + + return { + title: params.region ? "Partial screenshot captured" : "Screenshot captured", + output: `Screenshot captured: ${width}x${height} pixels`, + metadata: { + width, + height, + region: params.region, + } as any, + attachments: [ + { + type: "file", + mime: "image/png", + url: `data:image/png;base64,${base64Data}`, + }, + ], + } + } + + case "mouse_move": { + if (params.x === undefined || params.y === undefined) { + throw new Error("mouse_move action requires x and y coordinates") + } + + log.info("Moving mouse", { x: params.x, y: params.y }) + + const target = new nut.Point(params.x, params.y) + await nut.mouse.move(nut.straightTo(target)) + + return { + title: `Mouse moved to (${params.x}, ${params.y})`, + output: `Moved mouse cursor to coordinates (${params.x}, ${params.y})`, + metadata: { + x: params.x, + y: params.y, + } as any, + } + } + + case "mouse_click": { + const button = params.button || "left" + const buttonEnum = { + left: nut.Button.LEFT, + right: nut.Button.RIGHT, + middle: nut.Button.MIDDLE, + }[button] + + if (params.clickAt) { + log.info("Moving mouse to click position", { x: params.clickAt.x, y: params.clickAt.y }) + const target = new nut.Point(params.clickAt.x, params.clickAt.y) + await nut.mouse.move(nut.straightTo(target)) + } + + log.info("Performing mouse click", { button, doubleClick: params.doubleClick }) + + if (params.doubleClick) { + await nut.mouse.doubleClick(buttonEnum) + } else { + await nut.mouse.click(buttonEnum) + } + + return { + title: params.doubleClick ? `${button} double-click performed` : `${button} click performed`, + output: params.clickAt + ? `Performed ${button} ${params.doubleClick ? "double-" : ""}click at (${params.clickAt.x}, ${params.clickAt.y})` + : `Performed ${button} ${params.doubleClick ? "double-" : ""}click at current position`, + metadata: { + button, + doubleClick: params.doubleClick || false, + coordinates: params.clickAt, + } as any, + } + } + + case "type": { + if (!params.text) { + throw new Error("type action requires text parameter") + } + + log.info("Typing text", { length: params.text.length }) + + await nut.keyboard.type(params.text) + + return { + title: `Typed ${params.text.length} characters`, + output: `Typed: "${params.text}"`, + metadata: { + length: params.text.length, + } as any, + } + } + + case "key_click": { + if (!params.key) { + throw new Error("key_click action requires key parameter") + } + + const chord = [...(params.modifiers ?? []), params.key] + log.info("Clicking key", { key: params.key, modifiers: params.modifiers ?? [] }) + + await nut.keyboard.type(...chord.map((item) => key(nut, item))) + + return { + title: `Clicked ${chord.join("+")}`, + output: `Clicked key combination ${chord.join("+")}`, + metadata: { + key: params.key, + modifiers: params.modifiers ?? [], + } as any, + } + } + + default: + throw new Error(`Unknown action: ${(params as any).action}`) + } + }, + } +}) diff --git a/packages/opencode/src/tool/desktop.txt b/packages/opencode/src/tool/desktop.txt new file mode 100644 index 000000000000..093fb1882da3 --- /dev/null +++ b/packages/opencode/src/tool/desktop.txt @@ -0,0 +1,63 @@ +Native desktop automation tool that enables controlling the computer outside the terminal. Supports taking screenshots, moving the mouse, clicking, typing text, and clicking keyboard shortcuts. This replicates Anthropic's Computer Use tool functionality. + +## Actions + +### screenshot +Capture the entire screen or a specific region and return it as an image. Useful for seeing the current state of the desktop or specific applications. + +### mouse_move +Move the mouse cursor to specific screen coordinates (x, y). Coordinates are in pixels from the top-left corner of the screen. + +### mouse_click +Perform mouse clicks (left, right, or middle button) at the current cursor position or at specified coordinates. + +### type +Type text character by character as if entered from a physical keyboard. Supports all alphanumeric characters and common symbols. + +### key_click +Click a keyboard key or shortcut, including modifier combinations like Cmd+Space or Ctrl+C. + +## Usage Examples + +Take a screenshot: +``` +{"action": "screenshot"} +``` + +Move mouse to coordinates (500, 300): +``` +{"action": "mouse_move", "x": 500, "y": 300} +``` + +Left click at current position: +``` +{"action": "mouse_click", "button": "left"} +``` + +Type text: +``` +{"action": "type", "text": "Hello, World!"} +``` + +Click a modifier key: +``` +{"action": "key_click", "key": "cmd"} +``` + +Trigger a keyboard shortcut: +``` +{"action": "key_click", "key": "space", "modifiers": ["cmd"]} +``` + +Combined workflow - click a text field and type: +``` +{"action": "mouse_move", "x": 400, "y": 200} +{"action": "mouse_click", "button": "left"} +{"action": "type", "text": "user@example.com"} +``` + +## Notes +- Coordinates are absolute screen positions in pixels +- The screen origin (0, 0) is at the top-left corner +- On multi-monitor setups, coordinates may extend beyond primary display dimensions +- Screenshots are returned as base64-encoded image attachments diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 133a5018ad43..4b8b2e2b046e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -32,6 +32,12 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { DesktopTool } from "./desktop" +import { BrowserTool } from "./browser" +import { SwarmTool } from "./swarm" +import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree" +import { BriefTool } from "./brief" +import { SnipTool } from "./snip" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -129,6 +135,13 @@ export namespace ToolRegistry { TodoWriteTool, WebSearchTool, CodeSearchTool, + DesktopTool, + BrowserTool, + SwarmTool, + BriefTool, + SnipTool, + EnterWorktreeTool, + ExitWorktreeTool, SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), diff --git a/packages/opencode/src/tool/snip.ts b/packages/opencode/src/tool/snip.ts new file mode 100644 index 000000000000..41b30f15c8b1 --- /dev/null +++ b/packages/opencode/src/tool/snip.ts @@ -0,0 +1,29 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" +import { SessionCompaction } from "../session/compaction" + +export const SnipTool = Tool.define("snip", { + description: "Snip already-eligible low-value context from the active session.", + parameters: z.object({}), + async execute(_input, ctx) { + const msgs = await Session.messages({ sessionID: ctx.sessionID }) + const plan = SessionCompaction.prunePlan({ messages: msgs }) + if (!plan.parts.length) + return { + title: "Snip not needed", + output: "Snipped 0 eligible context parts.", + metadata: { snipped: 0 }, + } + for (const part of plan.parts) { + if (part.state.status !== "completed") continue + part.state.time.compacted = Date.now() + await Session.updatePart(part) + } + return { + title: "Snip complete", + output: `Snipped ${plan.parts.length} eligible context part${plan.parts.length === 1 ? "" : "s"}.`, + metadata: { snipped: plan.parts.length }, + } + }, +}) diff --git a/packages/opencode/src/tool/swarm.ts b/packages/opencode/src/tool/swarm.ts new file mode 100644 index 000000000000..29a6c6b84b85 --- /dev/null +++ b/packages/opencode/src/tool/swarm.ts @@ -0,0 +1,236 @@ +import { Tool } from "./tool" +import DESCRIPTION from "./swarm.txt" +import z from "zod" +import { Session } from "../session" +import { SessionID, MessageID, PartID } from "../session/schema" +import { MessageV2 } from "../session/message-v2" +import { Agent } from "../agent/agent" +import { SessionPrompt } from "../session/prompt" +import { defer } from "@/util/defer" +import { Config } from "../config/config" +import { Permission } from "@/permission" +import { errorMessage } from "../util/error" + +const parameters = z.object({ + tasks: z + .array( + z.object({ + description: z.string().describe("A short (3-5 words) description of the task"), + prompt: z.string().describe("The task for the sub-agent to perform"), + subagent_type: z.string().describe("The type of specialized agent to use for this task"), + }), + ) + .min(1, "Provide at least one task") + .max(10, "Maximum 10 tasks allowed in a single swarm call") + .describe("Array of independent tasks to execute in parallel across multiple sub-agents"), +}) + +type SwarmTask = z.infer["tasks"][number] + +type SwarmResult = + | { + success: true + description: string + output: string + sessionId: string + } + | { + success: false + description: string + error: string + } + +async function executeTask( + task: SwarmTask, + parentSessionID: SessionID, + parentMessageID: MessageID, + agentInfo: Agent.Info, + ctx: Tool.Context, +): Promise { + const config = await Config.get() + const hasTaskPermission = agentInfo.permission.some((rule) => rule.permission === "task") + const hasTodoWritePermission = agentInfo.permission.some((rule) => rule.permission === "todowrite") + + const session = await Session.create({ + parentID: parentSessionID, + title: task.description + ` (@${agentInfo.name} subagent)`, + permission: [ + ...(hasTodoWritePermission + ? [] + : [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(hasTaskPermission + ? [] + : [ + { + permission: "task" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*", + action: "allow" as const, + permission: t, + })) ?? []), + ], + }) + + const msg = await MessageV2.get({ sessionID: parentSessionID, messageID: parentMessageID }) + if (msg.info.role !== "assistant") throw new Error("Not an assistant message") + + const model = agentInfo.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + + const messageID = MessageID.ascending() + + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + + const promptParts = await SessionPrompt.resolvePromptParts(task.prompt) + + const result = await SessionPrompt.prompt({ + messageID, + sessionID: session.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: agentInfo.name, + tools: { + ...(hasTodoWritePermission ? {} : { todowrite: false }), + ...(hasTaskPermission ? {} : { task: false }), + swarm: false, + ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), + }, + parts: promptParts, + }) + + const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + + return { + success: true, + description: task.description, + output: text, + sessionId: session.id, + } +} + +export const SwarmTool = Tool.define("swarm", async (ctx) => { + const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) + + const caller = ctx?.agent + const accessibleAgents = caller + ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") + : agents + const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) + + const description = DESCRIPTION.replace( + "{agents}", + list + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n"), + ) + + return { + description, + parameters, + async execute(params: z.infer, ctx) { + await ctx.ask({ + permission: "swarm", + patterns: ["*"], + always: ["*"], + metadata: { + taskCount: params.tasks.length, + descriptions: params.tasks.map((t) => t.description), + }, + }) + + const config = await Config.get() + const maxConcurrency = config.experimental?.swarm_concurrency ?? 5 + + const results: SwarmResult[] = [] + const executing = new Set>() + + for (let i = 0; i < params.tasks.length; i++) { + const task = params.tasks[i] + const agent = await Agent.get(task.subagent_type) + + if (!agent) { + results.push({ + success: false, + description: task.description, + error: `Unknown agent type: ${task.subagent_type} is not a valid agent type`, + }) + continue + } + + const promise = executeTask(task, ctx.sessionID, ctx.messageID, agent, ctx).then( + (result) => { + results[i] = result + }, + (error) => { + results[i] = { + success: false, + description: task.description, + error: errorMessage(error), + } + }, + ) + + executing.add(promise) + promise.then(() => executing.delete(promise)) + + if (executing.size >= maxConcurrency) { + await Promise.race(executing) + } + } + + await Promise.all(executing) + + const successful = results.filter((r) => r.success).length + const failed = results.length - successful + + const outputParts = [ + `Swarm execution complete: ${successful}/${results.length} tasks successful${failed > 0 ? `, ${failed} failed` : ""}`, + "", + "", + ...results.map((result, idx) => { + const parts = [``] + if (result.success) { + parts.push(` ${result.sessionId}`) + parts.push(" ") + parts.push(...result.output.split("\n").map((l) => " " + l)) + parts.push(" ") + } else { + parts.push(` ${result.error}`) + } + parts.push("") + return parts.join("\n") + }), + "", + ] + + return { + title: `Swarm execution (${successful}/${results.length} successful)`, + metadata: { + total: results.length, + successful, + failed, + tasks: params.tasks.map((t) => ({ description: t.description, subagent_type: t.subagent_type })), + }, + output: outputParts.join("\n"), + } + }, + } +}) diff --git a/packages/opencode/src/tool/swarm.txt b/packages/opencode/src/tool/swarm.txt new file mode 100644 index 000000000000..a7807db51621 --- /dev/null +++ b/packages/opencode/src/tool/swarm.txt @@ -0,0 +1,35 @@ +Spawn multiple sub-agents in parallel using Bun's native workers for concurrent task execution. Each sub-agent runs independently in a separate worker thread, enabling parallel processing across multiple files, directories, or tasks. + +Use this tool when you need to: +- Execute multiple independent tasks simultaneously for faster completion +- Process multiple files or directories in parallel +- Run independent sub-agents that don't share state or have sequential dependencies +- Perform parallel research across different areas of a codebase + +IMPORTANT: Tasks should be independent with no shared state or sequential dependencies. The sub-agents run in complete isolation and cannot communicate with each other. + +Parameters: +- tasks: Array of task definitions, each with: + - description: Short 3-5 word description of the task + - prompt: Full task instructions for the sub-agent + - subagent_type: Type of specialized agent to use (e.g., "explore", "general") + +Example usage: +```json +{ + "tasks": [ + { + "description": "Find auth patterns", + "prompt": "Search the codebase for authentication middleware implementations in src/api/", + "subagent_type": "explore" + }, + { + "description": "Find error patterns", + "prompt": "Search for error handling patterns and custom Error classes", + "subagent_type": "explore" + } + ] +} +``` + +Results are returned as an array with each sub-agent's output, indexed by task order. \ No newline at end of file diff --git a/packages/opencode/src/tool/worktree-enter.txt b/packages/opencode/src/tool/worktree-enter.txt new file mode 100644 index 000000000000..1b2236bdafc7 --- /dev/null +++ b/packages/opencode/src/tool/worktree-enter.txt @@ -0,0 +1,5 @@ +Use this tool to create and enter an isolated git worktree sandbox for the current project. + +Call this tool when you need a safe sandbox for changes that should stay isolated from the primary workspace. + +The tool returns the created worktree info (name, branch, directory) for follow-up actions. diff --git a/packages/opencode/src/tool/worktree-exit.txt b/packages/opencode/src/tool/worktree-exit.txt new file mode 100644 index 000000000000..53fc8b6756c2 --- /dev/null +++ b/packages/opencode/src/tool/worktree-exit.txt @@ -0,0 +1,5 @@ +Use this tool to remove and exit an existing git worktree sandbox. + +Call this tool when work in a sandbox is complete and the worktree should be torn down. + +Provide the sandbox directory to remove. diff --git a/packages/opencode/src/tool/worktree.ts b/packages/opencode/src/tool/worktree.ts new file mode 100644 index 000000000000..8b59be0e3043 --- /dev/null +++ b/packages/opencode/src/tool/worktree.ts @@ -0,0 +1,58 @@ +import z from "zod" +import { Tool } from "./tool" +import { Worktree } from "../worktree" +import ENTER_DESCRIPTION from "./worktree-enter.txt" +import EXIT_DESCRIPTION from "./worktree-exit.txt" + +export const EnterWorktreeTool = Tool.define("worktree_enter", { + description: ENTER_DESCRIPTION, + parameters: Worktree.CreateInput, + async execute(input, ctx) { + const pattern = input.name?.trim() || "*" + await ctx.ask({ + permission: "worktree_enter", + patterns: [pattern], + always: ["*"], + metadata: { + name: input.name, + startCommand: input.startCommand, + }, + }) + + const info = await Worktree.create(input) + return { + title: `Entered worktree ${info.name}`, + output: [`name: ${info.name}`, `branch: ${info.branch}`, `directory: ${info.directory}`].join("\n"), + metadata: info, + } + }, +}) + +const exit = z.object({ + directory: Worktree.RemoveInput.shape.directory.describe("Sandbox worktree directory to remove"), +}) + +export const ExitWorktreeTool = Tool.define("worktree_exit", { + description: EXIT_DESCRIPTION, + parameters: exit, + async execute(input, ctx) { + await ctx.ask({ + permission: "worktree_exit", + patterns: [input.directory], + always: ["*"], + metadata: { + directory: input.directory, + }, + }) + + const removed = await Worktree.remove(input) + return { + title: removed ? "Removed worktree" : "Worktree removal skipped", + output: removed ? `Removed worktree: ${input.directory}` : `Worktree not removed: ${input.directory}`, + metadata: { + directory: input.directory, + removed, + }, + } + }, +}) diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts new file mode 100644 index 000000000000..64dc6a5b683f --- /dev/null +++ b/packages/opencode/src/trigger/index.ts @@ -0,0 +1,497 @@ +import { randomUUID } from "node:crypto" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" +import type { ProjectID } from "@/project/schema" +import { SessionPrompt } from "@/session/prompt" +import { SessionStatus } from "@/session/status" +import { SessionID } from "@/session/schema" +import { TriggerTable } from "@/session/session.sql" +import { Database, NotFoundError, eq } from "@/storage/db" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" +import z from "zod" +import { Log } from "../util/log" + +export namespace Trigger { + const log = Log.create({ service: "trigger" }) + + const Interval = z.object({ + type: z.literal("interval"), + interval: z.number().int().positive(), + }) + + const Once = z.object({ + type: z.literal("once"), + at: z.number().int().nonnegative(), + }) + + const ScheduleInfo = z.discriminatedUnion("type", [Interval, Once]) + const ScheduleInput = z.discriminatedUnion("type", [ + Interval.extend({ + interval: z.number().int().min(10).max(86_400_000), + }), + Once, + ]) + + const Action = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("command"), + sessionID: SessionID.zod, + command: z.string(), + arguments: z.string().optional(), + }), + z.object({ + type: z.literal("webhook"), + url: z.url(), + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), + }), + ]) + + const Source = z.enum(["schedule", "manual", "webhook"]) + type Source = z.infer + + const Status = z.enum(["success", "skipped", "failed"]) + const Last = z.object({ + source: Source, + status: Status, + error: z.string().min(1).optional(), + time: z.number().int().nonnegative(), + }) + type Last = z.infer + + export const Info = z + .object({ + id: z.string(), + schedule: ScheduleInfo, + action: Action.optional(), + webhook_secret: z.string().min(1).optional(), + enabled: z.boolean(), + runs: z.number().int().nonnegative(), + last: Last.optional(), + time: z.object({ + created: z.number().int().nonnegative(), + last: z.number().int().nonnegative().optional(), + next: z.number().int().nonnegative(), + }), + }) + .meta({ + ref: "Trigger", + }) + export type Info = z.infer + + const CreateBase = { + action: Action.optional(), + webhook_secret: z.string().min(1).optional(), + } + + export const CreateInput = z.union([ + z + .object({ + interval: z.number().int().min(10).max(86_400_000), + ...CreateBase, + }) + .transform((input) => ({ + ...input, + schedule: { + type: "interval" as const, + interval: input.interval, + }, + })), + z.object({ + schedule: ScheduleInput, + ...CreateBase, + }), + ]) + export type CreateInput = + | { + interval: number + action?: z.infer + webhook_secret?: string + } + | { + schedule: z.input + action?: z.infer + webhook_secret?: string + } + + export const Event = { + Fired: BusEvent.define( + "trigger.fired", + z.object({ + triggerID: z.string(), + runs: z.number().int().nonnegative(), + at: z.number().int().nonnegative(), + }), + ), + } + + type Err = InstanceType + + type State = { + create: (input: CreateInput) => Effect.Effect + get: (id: string) => Effect.Effect + list: () => Effect.Effect + fire: (id: string, source: Source) => Effect.Effect + enable: (id: string) => Effect.Effect + disable: (id: string) => Effect.Effect + delete: (id: string) => Effect.Effect + } + + export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly get: (id: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly fire: (id: string, source?: Source) => Effect.Effect + readonly enable: (id: string) => Effect.Effect + readonly disable: (id: string) => Effect.Effect + readonly delete: (id: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Trigger") {} + + const row = (project_id: ProjectID, item: Info, time_updated = Date.now()): typeof TriggerTable.$inferInsert => ({ + id: item.id, + project_id, + schedule: item.schedule, + action: item.action ?? null, + webhook_secret: item.webhook_secret ?? null, + enabled: item.enabled, + runs: item.runs, + last_source: item.last?.source ?? null, + last_status: item.last?.status ?? null, + last_error: item.last?.error ?? null, + time_created: item.time.created, + time_updated, + time_last: item.last?.time ?? item.time.last ?? null, + time_next: item.time.next, + }) + + const from = (row: typeof TriggerTable.$inferSelect): Info => ({ + id: row.id, + schedule: row.schedule, + ...(row.action ? { action: row.action } : {}), + ...(row.webhook_secret ? { webhook_secret: row.webhook_secret } : {}), + enabled: row.enabled, + runs: row.runs, + ...(row.last_source && row.last_status && row.time_last !== null + ? { + last: { + source: row.last_source, + status: row.last_status, + ...(row.last_error ? { error: row.last_error } : {}), + time: row.time_last, + }, + } + : {}), + time: { + created: row.time_created, + ...(row.time_last === null ? {} : { last: row.time_last }), + next: row.time_next, + }, + }) + + const ensure = Effect.sync(() => { + Database.Client() + .$client.query( + ` + CREATE TABLE IF NOT EXISTS trigger ( + id text PRIMARY KEY, + project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE, + schedule text NOT NULL, + action text, + webhook_secret text, + enabled integer NOT NULL, + runs integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_last integer, + time_next integer NOT NULL + ) + `, + ) + .run() + const cols = Database.Client().$client.query(`PRAGMA table_info(trigger)`).all() as { name: string }[] + if (!cols.some((col) => col.name === "webhook_secret")) { + Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN webhook_secret text`).run() + } + if (!cols.some((col) => col.name === "last_source")) { + Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_source text`).run() + } + if (!cols.some((col) => col.name === "last_status")) { + Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_status text`).run() + } + if (!cols.some((col) => col.name === "last_error")) { + Database.Client().$client.query(`ALTER TABLE trigger ADD COLUMN last_error text`).run() + } + Database.Client().$client.query(`CREATE INDEX IF NOT EXISTS trigger_project_idx ON trigger (project_id)`).run() + }) + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + + const state = yield* InstanceState.make( + Effect.fn("Trigger.state")(function* (ctx) { + yield* ensure + const data = new Map( + Database.use((db) => + db + .select() + .from(TriggerTable) + .where(eq(TriggerTable.project_id, ctx.project.id)) + .all() + .map((row) => [row.id, from(row)] as const), + ), + ) + + const save = Effect.fnUntraced(function* (next: Info) { + yield* Effect.sync(() => + Database.use((db) => + db + .insert(TriggerTable) + .values(row(ctx.project.id, next)) + .onConflictDoUpdate({ + target: TriggerTable.id, + set: row(ctx.project.id, next), + }) + .run(), + ), + ) + }) + + const delrow = Effect.fnUntraced(function* (id: string) { + yield* Effect.sync(() => Database.use((db) => db.delete(TriggerTable).where(eq(TriggerTable.id, id)).run())) + }) + + const get = Effect.fn("Trigger.get")((id: string) => + Effect.sync(() => { + const item = data.get(id) + if (item !== undefined) return item + throw new NotFoundError({ message: `Trigger not found: ${id}` }) + }), + ) + + const last = Effect.fnUntraced(function* (item: Info, next: Last) { + const out = { + ...item, + last: next, + time: { + ...item.time, + last: next.time, + }, + } + data.set(item.id, out) + yield* save(out) + return out + }) + + const run = Effect.fnUntraced(function* (item: Info, source: Source) { + const at = Date.now() + const next = + item.schedule.type === "interval" + ? { + ...item, + runs: item.runs + 1, + time: { + ...item.time, + last: at, + next: at + item.schedule.interval, + }, + } + : { + ...item, + enabled: false, + runs: item.runs + 1, + time: { + ...item.time, + last: at, + }, + } + data.set(item.id, next) + yield* save(next) + yield* bus.publish(Event.Fired, { + triggerID: item.id, + runs: next.runs, + at, + }) + const action = item.action + if (!action) return yield* last(next, { source, status: "success", time: at }) + + const exec = + action.type === "command" + ? Effect.gen(function* () { + const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID)) + if (st.type !== "idle") return yield* last(next, { source, status: "skipped", time: at }) + return yield* Effect.promise(() => + SessionPrompt.command({ + sessionID: action.sessionID, + command: action.command, + arguments: action.arguments ?? "", + }), + ).pipe(Effect.flatMap(() => last(next, { source, status: "success", time: at }))) + }) + : Effect.promise(async () => { + const res = await fetch(action.url, { + method: action.method, + headers: action.headers, + body: action.body, + }) + if (res.ok) return last(next, { source, status: "success", time: at }) + const err = await res.text() + return last(next, { + source, + status: "failed", + error: `HTTP ${res.status}: ${err || res.statusText}`, + time: at, + }) + }).pipe(Effect.flatMap((x) => x)) + + return yield* exec.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const err = Cause.squash(cause) + yield* Effect.sync(() => + log.error("trigger action failed", { + triggerID: item.id, + cause: Cause.pretty(cause), + }), + ) + return yield* last(next, { + source, + status: "failed", + error: err instanceof Error ? err.message : String(err), + time: at, + }) + }), + ), + ) + }) + + const tick = Effect.fnUntraced(function* () { + yield* Effect.forEach( + Array.from(data.values()).filter((item) => item.enabled && item.time.next <= Date.now()), + (item) => run(item, "schedule"), + { discard: true }, + ) + }) + + yield* tick().pipe( + Effect.catchCause((cause) => { + log.error("tick loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.millis(10))), + Effect.forkScoped, + ) + + const create = Effect.fn("Trigger.create")(function* (input: CreateInput) { + const now = Date.now() + const cfg = CreateInput.parse(input) + const item = { + id: `trg_${randomUUID().replaceAll("-", "")}`, + schedule: cfg.schedule, + action: cfg.action, + webhook_secret: cfg.webhook_secret, + enabled: true, + runs: 0, + time: { + created: now, + next: cfg.schedule.type === "interval" ? now + cfg.schedule.interval : cfg.schedule.at, + }, + } satisfies Info + data.set(item.id, item) + yield* save(item) + return item + }) + + const update = Effect.fnUntraced(function* (id: string, enabled: boolean) { + const item = yield* get(id) + const next = { ...item, enabled } + data.set(id, next) + yield* save(next) + return next + }) + + const list = Effect.fn("Trigger.list")(() => + Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), + ) + + const fire = Effect.fn("Trigger.fire")(function* (id: string, source: Source) { + return yield* run(yield* get(id), source) + }) + + const enable = Effect.fn("Trigger.enable")((id: string) => update(id, true)) + + const disable = Effect.fn("Trigger.disable")((id: string) => update(id, false)) + + const del = Effect.fn("Trigger.delete")(function* (id: string) { + yield* get(id) + data.delete(id) + yield* delrow(id) + }) + + return { create, get, list, fire, enable, disable, delete: del } + }), + ) + + return Service.of({ + create: Effect.fn("Trigger.create")(function* (input: CreateInput) { + return yield* InstanceState.useEffect(state, (svc) => svc.create(input)) + }), + get: Effect.fn("Trigger.get")(function* (id: string) { + return yield* InstanceState.useEffect(state, (svc) => svc.get(id)) + }), + list: Effect.fn("Trigger.list")(function* () { + return yield* InstanceState.useEffect(state, (svc) => svc.list()) + }), + fire: Effect.fn("Trigger.fire")(function* (id: string, source = "manual") { + return yield* InstanceState.useEffect(state, (svc) => svc.fire(id, source)) + }), + enable: Effect.fn("Trigger.enable")(function* (id: string) { + return yield* InstanceState.useEffect(state, (svc) => svc.enable(id)) + }), + disable: Effect.fn("Trigger.disable")(function* (id: string) { + return yield* InstanceState.useEffect(state, (svc) => svc.disable(id)) + }), + delete: Effect.fn("Trigger.delete")(function* (id: string) { + return yield* InstanceState.useEffect(state, (svc) => svc.delete(id)) + }), + }) + }), + ) + + const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function create(input: CreateInput) { + return runPromise((svc) => svc.create(input)) + } + + export async function list() { + return runPromise((svc) => svc.list()) + } + + export async function get(id: string) { + return runPromise((svc) => svc.get(id)) + } + + export async function enable(id: string) { + return runPromise((svc) => svc.enable(id)) + } + + export async function fire(id: string, source: Source = "manual") { + return runPromise((svc) => svc.fire(id, source)) + } + + export async function disable(id: string) { + return runPromise((svc) => svc.disable(id)) + } + + export async function remove(id: string) { + return runPromise((svc) => svc["delete"](id)) + } +} diff --git a/packages/opencode/test/build/chromium-bidi.test.ts b/packages/opencode/test/build/chromium-bidi.test.ts new file mode 100644 index 000000000000..8acecea71135 --- /dev/null +++ b/packages/opencode/test/build/chromium-bidi.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" +import { createRequire } from "module" + +const require = createRequire(import.meta.url) + +describe("build dependencies", () => { + test("resolves Bun compile chromium-bidi submodules used by Playwright", () => { + expect(require.resolve("chromium-bidi/lib/cjs/bidiMapper/BidiMapper")).toBeTruthy() + expect(require.resolve("chromium-bidi/lib/cjs/cdp/CdpConnection")).toBeTruthy() + }) +}) diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts new file mode 100644 index 000000000000..de84097435be --- /dev/null +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -0,0 +1,628 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import * as SDK from "@opencode-ai/sdk/v2" +import * as App from "../../src/cli/cmd/tui/app" +import { AttachCommand } from "../../src/cli/cmd/tui/attach" +import { RunCommand } from "../../src/cli/cmd/run" +import { resolveRemoteTarget } from "../../src/cli/cmd/remote" +import * as Win32 from "../../src/cli/cmd/tui/win32" +import { TuiConfig } from "../../src/config/tui" +import { Instance } from "../../src/project/instance" +import { UI } from "../../src/cli/ui" + +const exit = new Error("exit") + +afterEach(() => { + mock.restore() + process.exitCode = undefined +}) + +function client(input: unknown) { + return input as unknown as SDK.OpencodeClient +} + +function stopExit() { + return spyOn(process, "exit").mockImplementation(() => { + throw exit + }) +} + +function mockAttach() { + spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) + spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) + spyOn(TuiConfig, "get").mockResolvedValue({}) + spyOn(Instance, "provide").mockImplementation(async (input) => input.fn()) +} + +describe("remote preflight", () => { + test("attach preflights the remote directory before starting tui", async () => { + mockAttach() + const get = mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { get }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: false, + session: undefined, + fork: false, + password: undefined, + }) + + expect(get).toHaveBeenCalledTimes(1) + expect(tui).toHaveBeenCalledTimes(1) + }) + + test("attach scopes the remote client and tui to a workspace", async () => { + mockAttach() + const get = mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })) + const tui = spyOn(App, "tui").mockResolvedValue() + const create = spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { get }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: "ws_123", + continue: false, + session: undefined, + fork: false, + password: undefined, + }) + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://remote.test", + directory: "/srv/app", + experimental_workspaceID: "ws_123", + }), + ) + expect(tui).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + workspaceID: "ws_123", + }), + }), + ) + }) + + test("attach fails clearly when the remote directory does not match", async () => { + stopExit() + mockAttach() + const err = spyOn(UI, "error").mockImplementation(() => {}) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/other", + directory: "/srv/other", + }, + })), + }, + }), + ) + + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: false, + session: undefined, + fork: false, + password: undefined, + }), + ) + } catch (error) { + thrown = error + } + + expect(thrown).toBe(exit) + expect(err).toHaveBeenCalled() + expect(tui).not.toHaveBeenCalled() + }) + + test("attach fails before starting tui when the remote session is missing", async () => { + stopExit() + mockAttach() + const err = spyOn(UI, "error").mockImplementation(() => {}) + const get = mock(async () => { + throw new Error("not found") + }) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { get }, + }), + ) + + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: false, + session: "missing", + fork: false, + password: undefined, + }), + ) + } catch (error) { + thrown = error + } + + expect(thrown).toBe(exit) + expect(get).toHaveBeenCalledWith({ sessionID: "missing" }, { throwOnError: true }) + expect(err).toHaveBeenCalledWith(expect.stringContaining('Remote session "missing"')) + expect(tui).not.toHaveBeenCalled() + }) + + test("attach validates the remote session target before starting tui", async () => { + mockAttach() + const get = mock(async () => ({ + data: { + id: "sess_123", + }, + })) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { get }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: false, + session: "sess_123", + fork: false, + password: undefined, + }) + + expect(get).toHaveBeenCalledWith({ sessionID: "sess_123" }, { throwOnError: true }) + expect(tui).toHaveBeenCalledTimes(1) + }) + + test("attach announces the remote continue target before starting tui", async () => { + mockAttach() + const info = spyOn(UI, "println").mockImplementation(() => {}) + const list = mock(async () => ({ + data: [ + { + id: "sess_123", + title: "Remote draft", + parentID: undefined, + }, + ], + })) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { list }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: true, + session: undefined, + fork: false, + password: undefined, + }) + + expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true }) + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Continuing remote session"), + expect.stringContaining("Remote draft"), + expect.stringContaining("sess_123"), + ) + expect(tui).toHaveBeenCalledTimes(1) + expect(tui).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + continue: true, + sessionID: undefined, + remoteSessions: undefined, + }), + }), + ) + }) + + test("attach defers multiple remote root sessions to the tui picker", async () => { + mockAttach() + const list = mock(async () => ({ + data: [ + { + id: "sess_123", + title: "Remote draft", + parentID: undefined, + }, + { + id: "sess_456", + title: "Remote fix", + parentID: undefined, + }, + ], + })) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { list }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + workspace: undefined, + continue: true, + session: undefined, + fork: false, + password: undefined, + }) + + expect(tui).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + continue: false, + sessionID: undefined, + remoteSessions: [ + { + id: "sess_123", + title: "Remote draft", + }, + { + id: "sess_456", + title: "Remote fix", + }, + ], + }), + }), + ) + }) + + test("resolveRemoteTarget lets attach choose among multiple remote root sessions", async () => { + const list = mock(async () => ({ + data: [ + { + id: "sess_123", + title: "Remote draft", + parentID: undefined, + }, + { + id: "sess_456", + title: "Remote fix", + parentID: undefined, + }, + ], + })) + + const result = await resolveRemoteTarget({ + sdk: client({ + session: { list }, + }), + directory: "/srv/app", + continue: true, + pick: async (items) => items[1]?.id, + }) + + expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true }) + expect(result).toEqual({ + baseID: "sess_456", + picked: true, + title: "Remote fix", + }) + }) + + test("run --attach fails before creating a session when the remote is unreachable", async () => { + stopExit() + const err = spyOn(UI, "error").mockImplementation(() => {}) + const create = mock(async () => { + throw new Error("session.create should not run") + }) + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => { + throw new Error("connect ECONNREFUSED") + }), + }, + session: { + create, + }, + }), + ) + + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + + try { + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: false, + session: undefined, + fork: false, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: undefined, + workspace: undefined, + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) + } finally { + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + } + + expect(err).toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + }) + + test("run --attach fails before creating a session when the remote continue target is missing", async () => { + stopExit() + const err = spyOn(UI, "error").mockImplementation(() => {}) + const create = mock(async () => { + throw new Error("session.create should not run") + }) + const list = mock(async () => ({ + data: [], + })) + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { + list, + create, + }, + }), + ) + + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + + try { + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: true, + session: undefined, + fork: false, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: "/srv/app", + workspace: undefined, + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) + } finally { + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + } + + expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true }) + expect(err).toHaveBeenCalledWith(expect.stringContaining("No remote session found to continue")) + expect(create).not.toHaveBeenCalled() + }) + + test("run --attach fails before forking when the remote fork base is missing", async () => { + stopExit() + const err = spyOn(UI, "error").mockImplementation(() => {}) + const get = mock(async () => { + throw new Error("not found") + }) + const fork = mock(async () => { + throw new Error("session.fork should not run") + }) + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { + get, + fork, + }, + }), + ) + + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + + try { + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: false, + session: "missing", + fork: true, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: "/srv/app", + workspace: undefined, + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) + } finally { + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + } + + expect(get).toHaveBeenCalledWith({ sessionID: "missing" }, { throwOnError: true }) + expect(err).toHaveBeenCalledWith(expect.stringContaining('Remote fork base session "missing"')) + expect(fork).not.toHaveBeenCalled() + }) +}) diff --git a/packages/opencode/test/cli/trigger.test.ts b/packages/opencode/test/cli/trigger.test.ts new file mode 100644 index 000000000000..e178c104c1ff --- /dev/null +++ b/packages/opencode/test/cli/trigger.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import stripAnsi from "strip-ansi" +import { formatTriggerTable, parseTriggerCreateInput } from "../../src/cli/cmd/trigger" +import { SessionID } from "../../src/session/schema" + +describe("trigger cli create parsing", () => { + test("parses interval command triggers", () => { + const result = parseTriggerCreateInput({ + interval: 60_000, + session: "ses_123", + command: "summarize", + arguments: "--daily", + }) + + expect(result).toMatchObject({ + interval: 60_000, + action: { + type: "command", + sessionID: SessionID.make("ses_123"), + command: "summarize", + arguments: "--daily", + }, + }) + }) + + test("parses one-shot webhook triggers", () => { + expect( + parseTriggerCreateInput({ + at: 123, + webhook: "https://example.test/hook", + method: "POST", + body: '{"ok":true}', + webhookSecret: "secret", + }), + ).toEqual({ + schedule: { type: "once", at: 123 }, + action: { + type: "webhook", + url: "https://example.test/hook", + method: "POST", + body: '{"ok":true}', + }, + webhook_secret: "secret", + }) + }) + + test("rejects incomplete or conflicting create args", () => { + expect(parseTriggerCreateInput({})).toBe("Provide either --interval or --at") + expect(parseTriggerCreateInput({ interval: 1, at: 2 })).toBe("Choose either --interval or --at, not both") + expect(parseTriggerCreateInput({ interval: 1, session: "ses_123" })).toBe( + "Command actions require both --session and --command", + ) + expect( + parseTriggerCreateInput({ interval: 1, session: "ses_123", command: "x", webhook: "https://example.test" }), + ).toBe("Choose either a command action (--session + --command) or a webhook action (--webhook)") + }) +}) + +describe("trigger cli table formatting", () => { + test("renders trigger rows with schedule action and state", () => { + const output = stripAnsi( + formatTriggerTable([ + { + id: "trg_1", + schedule: { type: "interval", interval: 60_000 }, + action: { type: "webhook", url: "https://example.test", method: "POST" }, + enabled: true, + runs: 3, + last: { source: "manual", status: "success", time: 1 }, + time: { created: 1, next: 2, last: 1 }, + }, + ]), + ) + + expect(output).toContain("ID") + expect(output).toContain("every 60000ms") + expect(output).toContain("POST webhook") + expect(output).toContain("success") + }) +}) diff --git a/packages/opencode/test/cli/tui/attach-startup.test.ts b/packages/opencode/test/cli/tui/attach-startup.test.ts new file mode 100644 index 000000000000..408feaef0b6a --- /dev/null +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -0,0 +1,420 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" + +afterEach(() => { + mock.restore() +}) + +describe("attach startup", () => { + test("remote browser command exists for remote tui", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/app") + const fn = mod["getRemoteSessionCommand"] + expect(fn).toBeTypeOf("function") + + if (typeof fn !== "function") return + const result = fn({ + remote: true, + onSelect: mock(async () => {}), + }) + + expect(result).toEqual( + expect.objectContaining({ + title: "Browse remote sessions", + value: "remote.session.list", + category: "Session", + slash: { + name: "remote", + }, + }), + ) + }) + + test("remote browser fetches root remote sessions", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["listRemoteSessions"] + expect(fn).toBeTypeOf("function") + + const list = mock(async () => ({ + data: [ + { + id: "sess_root", + title: "Root draft", + }, + { + id: "sess_child", + title: "Child fix", + parentID: "sess_root", + }, + { + id: "sess_next", + title: "Next root", + }, + ], + })) + + if (typeof fn !== "function") return + const result = await fn({ + sdk: { + client: { + session: { + list, + }, + }, + }, + }) + + expect(list).toHaveBeenCalledWith({ + roots: true, + }) + expect(result).toEqual([ + { + id: "sess_root", + title: "Root draft", + }, + { + id: "sess_next", + title: "Next root", + }, + ]) + }) + + test("remote browser selection reuses the existing child browse flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["selectRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + replace: mock(() => {}), + } + const sdk = { + client: { + session: { + children: mock(async () => ({ + data: [ + { + id: "sess_child", + title: "Child fix", + }, + ], + })), + fork: mock(async () => ({ + data: { + id: "sess_forked", + }, + })), + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_root", + title: "Root draft", + route, + dialog, + sdk, + toast, + }) + + expect(dialog.replace).toHaveBeenCalledTimes(1) + expect(dialog.clear).not.toHaveBeenCalled() + expect(route.navigate).not.toHaveBeenCalled() + expect(sdk.client.session.fork).not.toHaveBeenCalled() + }) + + test("fork browse copy makes the fork target explicit", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["getRemoteBrowse"] + expect(fn).toBeTypeOf("function") + + if (typeof fn !== "function") return + const result = fn({ + root: { + id: "sess_root", + title: "Root draft", + }, + sessions: [ + { + id: "sess_child", + title: "Child fix", + }, + ], + fork: true, + }) + + expect(result).toEqual({ + title: "Fork from remote session", + options: [ + { + title: "Root draft", + value: "sess_root", + footer: "sess_root", + description: "Fork from root session", + }, + { + title: "Child fix", + value: "sess_child", + footer: "sess_child", + description: "Fork from child session", + }, + ], + }) + }) + + test("root without children navigates inside the tui flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["selectRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + replace: mock(() => {}), + } + const children = mock(async () => ({ + data: [], + })) + const sdk = { + client: { + session: { + children, + fork: mock(async () => ({ + data: { + id: "sess_forked", + }, + })), + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_456", + fork: false, + route, + dialog, + sdk, + toast, + }) + + expect(route.navigate).toHaveBeenCalledWith({ + type: "session", + sessionID: "sess_456", + }) + expect(children).toHaveBeenCalledWith({ + sessionID: "sess_456", + }) + expect(dialog.clear).toHaveBeenCalledTimes(1) + expect(dialog.replace).not.toHaveBeenCalled() + expect(sdk.client.session.fork).not.toHaveBeenCalled() + expect(toast.show).not.toHaveBeenCalled() + }) + + test("root with children opens the child browse flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["selectRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + replace: mock(() => {}), + } + const children = mock(async () => ({ + data: [ + { + id: "sess_child", + title: "Child fix", + }, + ], + })) + const sdk = { + client: { + session: { + children, + fork: mock(async () => ({ + data: { + id: "sess_forked", + }, + })), + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_root", + title: "Root draft", + fork: false, + route, + dialog, + sdk, + toast, + }) + + expect(children).toHaveBeenCalledWith({ + sessionID: "sess_root", + }) + expect(dialog.replace).toHaveBeenCalledTimes(1) + expect(dialog.clear).not.toHaveBeenCalled() + expect(route.navigate).not.toHaveBeenCalled() + expect(sdk.client.session.fork).not.toHaveBeenCalled() + }) + + test("selected child navigates inside the tui flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["openRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + } + const sdk = { + client: { + session: { + fork: mock(async () => ({ + data: { + id: "sess_forked", + }, + })), + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_child", + fork: false, + route, + dialog, + sdk, + toast, + }) + + expect(route.navigate).toHaveBeenCalledWith({ + type: "session", + sessionID: "sess_child", + }) + expect(dialog.clear).toHaveBeenCalledTimes(1) + expect(sdk.client.session.fork).not.toHaveBeenCalled() + expect(toast.show).not.toHaveBeenCalled() + }) + + test("fork mode root selection forks from that root", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["openRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + } + const fork = mock(async () => ({ + data: { + id: "sess_forked_root", + }, + })) + const sdk = { + client: { + session: { + fork, + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_root", + fork: true, + route, + dialog, + sdk, + toast, + }) + + expect(fork).toHaveBeenCalledWith({ + sessionID: "sess_root", + }) + expect(route.navigate).toHaveBeenCalledWith({ + type: "session", + sessionID: "sess_forked_root", + }) + expect(dialog.clear).toHaveBeenCalledTimes(1) + expect(toast.show).not.toHaveBeenCalled() + }) + + test("fork mode child selection forks from that child", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["openRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + } + const fork = mock(async () => ({ + data: { + id: "sess_forked_child", + }, + })) + const sdk = { + client: { + session: { + fork, + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_child", + fork: true, + route, + dialog, + sdk, + toast, + }) + + expect(fork).toHaveBeenCalledWith({ + sessionID: "sess_child", + }) + expect(route.navigate).toHaveBeenCalledWith({ + type: "session", + sessionID: "sess_forked_child", + }) + expect(dialog.clear).toHaveBeenCalledTimes(1) + expect(toast.show).not.toHaveBeenCalled() + }) +}) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts new file mode 100644 index 000000000000..f1bb0b0b6cd5 --- /dev/null +++ b/packages/opencode/test/memory/memory.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from "bun:test" +import { Effect, Option, Layer } from "effect" +import { Database } from "../../src/storage/db" +import { MemoryRepo } from "../../src/memory/repo" +import { MemoryID, RuleID, APIKeyID } from "../../src/memory/schema" + +describe("Memory", () => { + beforeEach(() => { + Database.close() + }) + + it("should set and get a preference", async () => { + const testLayer = MemoryRepo.layer + const result = await Effect.runPromise( + Effect.gen(function* () { + const repo = yield* MemoryRepo + const id = MemoryID.make("pref-1") + yield* repo.setPreference({ + id, + key: "test-key", + value: "test-value", + type: "string", + description: "Test preference", + }) + return yield* repo.getPreference("test-key") + }).pipe(Effect.provide(testLayer)), + ) + + expect(Option.isSome(result)).toBe(true) + if (Option.isSome(result)) { + const value = result.value as { key: string; value: string; type: string } + expect(value.key).toBe("test-key") + expect(value.value).toBe("test-value") + expect(value.type).toBe("string") + } + }) + + it("should set and get rules for a project", async () => { + const testLayer = MemoryRepo.layer + const result = await Effect.runPromise( + Effect.gen(function* () { + const repo = yield* MemoryRepo + const id = RuleID.make("rule-1") + yield* repo.setRule({ + id, + projectID: "test-project", + pattern: "*.ts", + rule: "Use strict TypeScript", + priority: 1, + enabled: true, + }) + return yield* repo.getRulesForProject("test-project") + }).pipe(Effect.provide(testLayer)), + ) + + expect(Array.isArray(result)).toBe(true) + }) + + it("should set and get API keys", async () => { + const testLayer = MemoryRepo.layer + const result = await Effect.runPromise( + Effect.gen(function* () { + const repo = yield* MemoryRepo + const id = APIKeyID.make("key-1") + yield* repo.setAPIKey({ + id, + provider: "openai", + keyName: "api-key-1", + encryptedValue: "encrypted-secret", + description: "Test API key", + }) + return yield* repo.getAPIKeys() + }).pipe(Effect.provide(testLayer)), + ) + + expect(Array.isArray(result)).toBe(true) + }) +}) diff --git a/packages/opencode/test/permission/router.test.ts b/packages/opencode/test/permission/router.test.ts new file mode 100644 index 000000000000..ca80030c0ece --- /dev/null +++ b/packages/opencode/test/permission/router.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test" +import { PermissionRouter, PermissionClassifier } from "@/permission" + +describe("PermissionRouter", () => { + test("should have built-in tool classifications", () => { + expect(PermissionRouter.BuiltinToolClassifications.read).toBeDefined() + expect(PermissionRouter.BuiltinToolClassifications.bash).toBeDefined() + expect(PermissionRouter.BuiltinToolClassifications.write).toBeDefined() + expect(PermissionRouter.BuiltinToolClassifications.edit).toBeDefined() + }) + + test("read tool should be read-only", () => { + const readTool = PermissionRouter.BuiltinToolClassifications.read + expect(readTool.flags.isReadOnly).toBe(true) + expect(readTool.flags.isDestructive).toBe(false) + expect(readTool.defaultRisk).toBe("low") + expect(readTool.requiredApprovals).toContain("auto") + }) + + test("bash tool should be destructive and require user approval", () => { + const bashTool = PermissionRouter.BuiltinToolClassifications.bash + expect(bashTool.flags.isReadOnly).toBe(false) + expect(bashTool.flags.isDestructive).toBe(true) + expect(bashTool.flags.isSystem).toBe(true) + expect(bashTool.defaultRisk).toBe("high") + expect(bashTool.requiredApprovals).toContain("user") + }) + + test("write tool should be destructive", () => { + const writeTool = PermissionRouter.BuiltinToolClassifications.write + expect(writeTool.flags.isDestructive).toBe(true) + expect(writeTool.defaultRisk).toBe("medium") + expect(writeTool.requiredApprovals).toContain("user") + }) + + test("network tools should use classifier", () => { + const webfetch = PermissionRouter.BuiltinToolClassifications.webfetch + expect(webfetch.flags.isNetwork).toBe(true) + expect(webfetch.requiredApprovals).toContain("classifier") + + const websearch = PermissionRouter.BuiltinToolClassifications.websearch + expect(websearch.flags.isNetwork).toBe(true) + expect(websearch.requiredApprovals).toContain("classifier") + }) +}) + +describe("PermissionClassifier", () => { + test("should parse valid classification response", () => { + const response = JSON.stringify({ + riskLevel: "low", + confidence: 0.95, + reasoning: "Safe read operation", + suggestedAction: "allow", + requiresHumanReview: false, + }) + + const result = PermissionClassifier.parseResponse(response) + expect(result.riskLevel).toBe("low") + expect(result.confidence).toBe(0.95) + expect(result.suggestedAction).toBe("allow") + expect(result.requiresHumanReview).toBe(false) + }) + + test("should handle invalid JSON gracefully", () => { + const result = PermissionClassifier.parseResponse("invalid json") + expect(result.riskLevel).toBe("medium") + expect(result.confidence).toBe(0.5) + expect(result.suggestedAction).toBe("ask") + expect(result.requiresHumanReview).toBe(true) + }) + + test("should determine action for critical risk", () => { + const classification = { + riskLevel: "critical" as const, + confidence: 0.9, + reasoning: "Dangerous", + suggestedAction: "deny" as const, + requiresHumanReview: true, + } + + const def = PermissionRouter.BuiltinToolClassifications.bash + const action = PermissionClassifier.determineAction(classification, def) + expect(action).toBe("deny") + }) + + test("should determine action for low risk read-only tool", () => { + const classification = { + riskLevel: "low" as const, + confidence: 0.95, + reasoning: "Safe", + suggestedAction: "allow" as const, + requiresHumanReview: false, + } + + const def = PermissionRouter.BuiltinToolClassifications.read + const action = PermissionClassifier.determineAction(classification, def) + expect(action).toBe("allow") + }) + + test("should generate cache key consistently", () => { + const req = { + toolId: "read", + params: { filePath: "/test.txt" }, + sessionID: "test-session", + context: { cwd: "/", previousCalls: [] }, + } + + const key1 = PermissionClassifier.getCacheKey(req) + const key2 = PermissionClassifier.getCacheKey(req) + expect(key1).toBe(key2) + expect(key1).toContain("read") + }) + + test("should create prompt with tool info", () => { + const req = { + toolId: "bash", + params: { command: "ls -la" }, + sessionID: "test", + context: { cwd: "/home", previousCalls: [] }, + } + + const def = PermissionRouter.BuiltinToolClassifications.bash + const prompt = PermissionClassifier.createPrompt(req, def) + + expect(prompt).toContain("bash") + expect(prompt).toContain("ls -la") + expect(prompt).toContain("/home") + expect(prompt).toContain("Destructive: true") + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b1..811175f9cf1e 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test" +import { mkdir } from "fs/promises" +import path from "path" +import { Database as Sqlite } from "bun:sqlite" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { Session } from "../../src/session" @@ -8,6 +11,93 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) describe("Session.listGlobal", () => { + test("includes sessions from sibling local channel databases", async () => { + await using tmp = await tmpdir() + + const data = path.join(tmp.path, "share", "opencode") + await mkdir(data, { recursive: true }) + + const seed = (file: string, id: string, title: string, worktree: string, updated: number) => { + const db = new Sqlite(path.join(data, file)) + db.exec(` + create table project ( + id text primary key, + name text, + worktree text not null + ); + create table session ( + id text primary key, + project_id text not null, + workspace_id text, + parent_id text, + slug text not null, + directory text not null, + title text not null, + version text not null, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + revert text, + permission text, + time_created integer not null, + time_updated integer not null, + time_compacting integer, + time_archived integer + ); + `) + db.query(`insert into project (id, name, worktree) values (?, ?, ?)`).run("proj-" + id, title, worktree) + db.query( + `insert into session ( + id, project_id, workspace_id, parent_id, slug, directory, title, version, + share_url, summary_additions, summary_deletions, summary_files, summary_diffs, + revert, permission, time_created, time_updated, time_compacting, time_archived + ) values (?, ?, null, null, ?, ?, ?, '0', null, null, null, null, null, null, null, ?, ?, null, null)`, + ).run(id, "proj-" + id, id, worktree, title, updated - 1, updated) + db.close() + } + + seed("opencode-dev.db", "ses_dev", "dev session", "/tmp/dev", 200) + + const cmd = [ + "bun", + "-e", + [ + 'const { Session } = await import("./src/session")', + "const rows = [...Session.listGlobal({ limit: 10 })]", + "console.log(JSON.stringify(rows.map((row) => ({ id: row.id, title: row.title, worktree: row.project?.worktree }))))", + ].join(";"), + ] + + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[0] !== "OPENCODE_DB" && entry[1] !== undefined, + ), + ) + + const proc = Bun.spawn(cmd, { + cwd: path.join(import.meta.dir, "..", ".."), + stdout: "pipe", + stderr: "pipe", + env: { + ...env, + XDG_DATA_HOME: path.join(tmp.path, "share"), + XDG_CACHE_HOME: path.join(tmp.path, "cache"), + XDG_CONFIG_HOME: path.join(tmp.path, "config"), + XDG_STATE_HOME: path.join(tmp.path, "state"), + OPENCODE_TEST_HOME: path.join(tmp.path, "home"), + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + }, + }) + + const text = await new Response(proc.stdout).text() + const code = await proc.exited + + expect(code).toBe(0) + expect(JSON.parse(text)).toEqual([{ id: "ses_dev", title: "dev session", worktree: "/tmp/dev" }]) + }) + test("lists sessions across projects with project metadata", async () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts new file mode 100644 index 000000000000..5c5031e2122f --- /dev/null +++ b/packages/opencode/test/server/trigger.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Trigger } from "../../src/trigger" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +function auth(password: string, username = "opencode") { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +beforeEach(async () => { + await resetDatabase() +}) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("trigger routes", () => { + test("creates and lists triggers", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + + const create = await app.request("/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: 20 }), + }) + + expect(create.status).toBe(200) + const item = await create.json() + expect(item).toMatchObject({ + schedule: { interval: 20 }, + runs: 0, + }) + + await Bun.sleep(80) + + const list = await app.request("/trigger") + expect(list.status).toBe(200) + const body = await list.json() + expect(body).toHaveLength(1) + expect(body[0]).toMatchObject({ + id: item.id, + schedule: { type: "interval", interval: 20 }, + }) + expect(body[0].runs).toBeGreaterThan(0) + }, + }) + }) + + test("returns trigger detail with current enabled state", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + const create = await app.request("/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: 20 }), + }) + const item = await create.json() + + const off = await app.request(`/trigger/${item.id}/disable`, { + method: "POST", + }) + expect(off.status).toBe(200) + + const detail = await app.request(`/trigger/${item.id}`) + expect(detail.status).toBe(200) + expect(await detail.json()).toMatchObject({ + id: item.id, + enabled: false, + schedule: { type: "interval", interval: 20 }, + }) + }, + }) + }) + + test("enables and deletes triggers", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + const create = await app.request("/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: 20 }), + }) + const item = await create.json() + + const off = await app.request(`/trigger/${item.id}/disable`, { + method: "POST", + }) + expect(off.status).toBe(200) + + const on = await app.request(`/trigger/${item.id}/enable`, { + method: "POST", + }) + expect(on.status).toBe(200) + expect(await on.json()).toMatchObject({ id: item.id, enabled: true }) + + const del = await app.request(`/trigger/${item.id}`, { + method: "DELETE", + }) + expect(del.status).toBe(200) + + const list = await app.request("/trigger") + expect(await list.json()).toEqual([]) + + const detail = await app.request(`/trigger/${item.id}`) + expect(detail.status).toBe(404) + }, + }) + }) + + test("fires trigger now and returns updated state", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + const create = await app.request("/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: 5_000 }), + }) + const item = await create.json() + + const fire = await app.request(`/trigger/${item.id}/fire`, { + method: "POST", + }) + + expect(fire.status).toBe(200) + expect(await fire.json()).toMatchObject({ + id: item.id, + runs: 1, + last: { + source: "manual", + status: "success", + time: expect.any(Number), + }, + time: { + created: item.time.created, + last: expect.any(Number), + }, + }) + }, + }) + }) + + test("fires trigger from webhook endpoint", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000 }), + }) + const app = Server.ControlPlaneRoutes() + + const fire = await app.request(`/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + }) + + expect(fire.status).toBe(200) + expect(await fire.json()).toMatchObject({ + id: item.id, + runs: 1, + last: { + source: "webhook", + status: "success", + time: expect.any(Number), + }, + time: { + created: item.time.created, + last: expect.any(Number), + }, + }) + }) + + test("fires webhook without trigger secret", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000 }), + }) + const app = Server.ControlPlaneRoutes() + + const fire = await app.request(`/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + }) + + expect(fire.status).toBe(200) + expect(await fire.json()).toMatchObject({ id: item.id, runs: 1 }) + }) + + test("rejects webhook without matching trigger secret", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000, webhook_secret: "topsecret" }), + }) + const app = Server.ControlPlaneRoutes() + const url = `/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}` + + const miss = await app.request(url, { + method: "POST", + }) + expect(miss.status).toBe(401) + + const bad = await app.request(url, { + method: "POST", + headers: { + "X-Trigger-Secret": "wrong", + }, + }) + expect(bad.status).toBe(401) + + const good = await app.request(url, { + method: "POST", + headers: { + "X-Trigger-Secret": "topsecret", + }, + }) + expect(good.status).toBe(200) + expect(await good.json()).toMatchObject({ id: item.id, runs: 1 }) + }) + + test("requires server auth for webhook trigger fire", async () => { + await using tmp = await tmpdir({ git: true }) + const item = await Instance.provide({ + directory: tmp.path, + fn: () => Trigger.create({ interval: 5_000 }), + }) + const prev = process.env.OPENCODE_SERVER_PASSWORD + delete process.env.OPENCODE_SERVER_USERNAME + process.env.OPENCODE_SERVER_PASSWORD = "secret" + + try { + const app = Server.ControlPlaneRoutes() + const url = `/trigger/${item.id}/fire/webhook?directory=${encodeURIComponent(tmp.path)}` + + const bad = await app.request(url, { method: "POST" }) + expect(bad.status).toBe(401) + + const good = await app.request(url, { + method: "POST", + headers: { + Authorization: auth("secret"), + }, + }) + + expect(good.status).toBe(200) + expect(await good.json()).toMatchObject({ id: item.id, runs: 1 }) + } finally { + if (prev === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = prev + } + }) +}) diff --git a/packages/opencode/test/tool/brief.test.ts b/packages/opencode/test/tool/brief.test.ts new file mode 100644 index 000000000000..18efc893988a --- /dev/null +++ b/packages/opencode/test/tool/brief.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { BriefTool } from "../../src/tool/brief" +import { MessageID, PartID } from "../../src/session/schema" +import { Session } from "../../src/session" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { SessionCompaction } from "../../src/session/compaction" +import { SessionPrompt } from "../../src/session/prompt" +import { ToolRegistry } from "../../src/tool/registry" +import { Agent } from "../../src/agent/agent" +import { Provider } from "../../src/provider/provider" + +afterEach(async () => { + mock.restore() + await Instance.disposeAll() +}) + +describe("tool.brief", () => { + test("uses latest user agent and model from current session", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("old") }, + time: { created: Date.now() - 1000 }, + }) + const latest = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "plan", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("latest") }, + time: { created: Date.now() }, + }) + await Session.updatePart({ + id: PartID.ascending(), + messageID: latest.id, + sessionID: session.id, + type: "text", + text: "latest prompt", + }) + + const create = spyOn(SessionCompaction, "create").mockResolvedValue(undefined) + const loop = spyOn(SessionPrompt, "loop").mockResolvedValue( + {} as Awaited>, + ) + + const tool = await BriefTool.init() + const result = await tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_tool"), + callID: "call_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ) + + expect(create).toHaveBeenCalledWith({ + sessionID: session.id, + agent: "plan", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("latest") }, + auto: false, + }) + expect(loop).toHaveBeenCalledWith({ sessionID: session.id }) + expect(result.title).toContain("Brief") + }, + }) + }) + + test("falls back to default agent and model when session has no user messages", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const def = { providerID: ProviderID.make("fallback"), modelID: ModelID.make("fallback") } + const agent = spyOn(Agent, "defaultAgent").mockResolvedValue("build") + const model = spyOn(Provider, "defaultModel").mockResolvedValue(def) + const create = spyOn(SessionCompaction, "create").mockResolvedValue(undefined) + spyOn(SessionPrompt, "loop").mockResolvedValue({} as Awaited>) + + const tool = await BriefTool.init() + await tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_tool"), + callID: "call_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ) + + expect(agent).toHaveBeenCalledTimes(1) + expect(model).toHaveBeenCalledTimes(1) + expect(create).toHaveBeenCalledWith({ + sessionID: session.id, + agent: "build", + model: def, + auto: false, + }) + }, + }) + }) + + test("is registered in tool registry", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("brief") + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/desktop.test.ts b/packages/opencode/test/tool/desktop.test.ts new file mode 100644 index 000000000000..f47ca447e4bb --- /dev/null +++ b/packages/opencode/test/tool/desktop.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import { DesktopTool, resolveNutJsImportSpecifier } from "../../src/tool/desktop" +import { SessionID, MessageID } from "../../src/session/schema" + +const calls: unknown[][] = [] + +mock.module("@nut-tree-fork/nut-js", () => ({ + keyboard: { + type: async (...input: unknown[]) => { + calls.push(input) + }, + }, + screen: { + grab: async () => ({ + width: 1, + height: 1, + data: Buffer.from([255, 0, 0, 255]), + colorMode: undefined, + }), + width: async () => 1, + height: async () => 1, + }, + Key: { + LeftSuper: "LeftSuper", + LeftControl: "LeftControl", + LeftAlt: "LeftAlt", + LeftShift: "LeftShift", + Space: "Space", + }, +})) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +beforeEach(() => { + calls.length = 0 +}) + +describe("resolveNutJsImportSpecifier", () => { + test("uses a real on-disk helper when running from Bun's compiled filesystem", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-desktop-")) + const execPath = path.join(tempDir, "opencode") + const helperPath = path.join(tempDir, "desktop.runtime.mjs") + + await fs.writeFile(execPath, "") + await fs.writeFile(helperPath, "export default { marker: 'desktop-runtime-helper' }") + + const specifier = resolveNutJsImportSpecifier("file:///$bunfs/root/src/cli/cmd/tui/worker.js", execPath) + const expectedHelperPath = pathToFileURL(await fs.realpath(helperPath)).href + + expect(specifier).toBe(expectedHelperPath) + + const loaded = await import(specifier) + expect(loaded.default.marker).toBe("desktop-runtime-helper") + }) + + test("uses the package import during normal source runtime", () => { + expect(resolveNutJsImportSpecifier("file:///tmp/opencode/src/tool/desktop.ts", "/usr/local/bin/bun")).toBe( + "@nut-tree-fork/nut-js", + ) + }) +}) + +describe("DesktopTool", () => { + test("clicks a modifier key", async () => { + const desktop = await DesktopTool.init() + const result = await desktop.execute({ action: "key_click", key: "cmd" }, ctx) + + expect(calls).toEqual([["LeftSuper"]]) + expect(result.output).toContain("cmd") + }) + + test("clicks a shortcut with modifiers", async () => { + const desktop = await DesktopTool.init() + const result = await desktop.execute({ action: "key_click", key: "space", modifiers: ["cmd"] }, ctx) + + expect(calls).toEqual([["LeftSuper", "Space"]]) + expect(result.output).toContain("cmd+space") + }) + + test("returns a PNG attachment for screenshots", async () => { + const desktop = await DesktopTool.init() + const result = await desktop.execute({ action: "screenshot" }, ctx) + + expect(result.output).toContain("1x1") + expect(result.attachments).toHaveLength(1) + expect(result.attachments?.[0]?.type).toBe("file") + expect(result.attachments?.[0]?.mime).toBe("image/png") + expect(result.attachments?.[0]?.url.startsWith("data:image/png;base64,")).toBe(true) + }) +}) diff --git a/packages/opencode/test/tool/snip.test.ts b/packages/opencode/test/tool/snip.test.ts new file mode 100644 index 000000000000..9e38ccf10beb --- /dev/null +++ b/packages/opencode/test/tool/snip.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageID, PartID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { SnipTool } from "../../src/tool/snip" +import { ToolRegistry } from "../../src/tool/registry" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("tool.snip", () => { + test("compacts only parts eligible under session prune semantics", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test") } + const first = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: ref, + time: { created: Date.now() - 3 }, + }) + const reply = await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + parentID: first.id, + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + providerID: ref.providerID, + modelID: ref.modelID, + time: { created: Date.now() - 2 }, + finish: "end_turn", + }) + const part = await Session.updatePart({ + id: PartID.ascending(), + messageID: reply.id, + sessionID: session.id, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "x".repeat(200_000), + title: "done", + metadata: {}, + time: { start: Date.now() - 2, end: Date.now() - 2 }, + }, + }) + await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: ref, + time: { created: Date.now() - 1 }, + }) + await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + + const tool = await SnipTool.init() + const out = await tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_tool"), + callID: "call_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ) + + const msgs = await Session.messages({ sessionID: session.id }) + const next = msgs.flatMap((item) => item.parts).find((item) => item.type === "tool" && item.id === part.id) + expect(next?.type).toBe("tool") + if (next?.type === "tool" && next.state.status === "completed") { + expect(next.state.time.compacted).toBeNumber() + } + expect(out.output).toContain("1") + }, + }) + }) + + test("returns no-op when current session has no eligible parts", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test") } + const first = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: ref, + time: { created: Date.now() - 1 }, + }) + const reply = await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + parentID: first.id, + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + providerID: ref.providerID, + modelID: ref.modelID, + time: { created: Date.now() }, + finish: "end_turn", + }) + const part = await Session.updatePart({ + id: PartID.ascending(), + messageID: reply.id, + sessionID: session.id, + type: "tool", + callID: "call_2", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "small", + title: "done", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + + const tool = await SnipTool.init() + const out = await tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_tool"), + callID: "call_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ) + + const msgs = await Session.messages({ sessionID: session.id }) + const next = msgs.flatMap((item) => item.parts).find((item) => item.type === "tool" && item.id === part.id) + expect(next?.type).toBe("tool") + if (next?.type === "tool" && next.state.status === "completed") { + expect(next.state.time.compacted).toBeUndefined() + } + expect(out.output).toContain("0") + }, + }) + }) + + test("is registered in tool registry", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("snip") + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/worktree.test.ts b/packages/opencode/test/tool/worktree.test.ts new file mode 100644 index 000000000000..101324ac3918 --- /dev/null +++ b/packages/opencode/test/tool/worktree.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { SessionID, MessageID } from "../../src/session/schema" +import { EnterWorktreeTool, ExitWorktreeTool } from "../../src/tool/worktree" +import * as WorktreeModule from "../../src/worktree" +import { ToolRegistry } from "../../src/tool/registry" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { Permission } from "../../src/permission" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.worktree", () => { + let create: ReturnType + let remove: ReturnType + + beforeEach(() => { + create = spyOn(WorktreeModule.Worktree, "create") + remove = spyOn(WorktreeModule.Worktree, "remove") + }) + + afterEach(async () => { + create.mockRestore() + remove.mockRestore() + await Instance.disposeAll() + }) + + test("enter requests permission and creates worktree", async () => { + const info = { + name: "sandbox", + branch: "opencode/sandbox", + directory: "/tmp/sandbox", + } + create.mockResolvedValue(info) + + const req: Array<{ permission: string; patterns: string[] }> = [] + const tool = await EnterWorktreeTool.init() + const result = await tool.execute( + { name: "sandbox", startCommand: "bun install" }, + { + ...ctx, + ask: async (input: Omit) => { + req.push({ permission: input.permission, patterns: input.patterns }) + }, + }, + ) + + expect(req).toEqual([{ permission: "worktree_enter", patterns: ["sandbox"] }]) + expect(create).toHaveBeenCalledWith({ name: "sandbox", startCommand: "bun install" }) + expect(result.metadata).toMatchObject(info) + expect(result.output).toContain("/tmp/sandbox") + }) + + test("exit requests permission and removes worktree", async () => { + remove.mockResolvedValue(true) + const req: Array<{ permission: string; patterns: string[] }> = [] + const tool = await ExitWorktreeTool.init() + + const result = await tool.execute( + { + directory: "/tmp/sandbox", + }, + { + ...ctx, + ask: async (input: Omit) => { + req.push({ permission: input.permission, patterns: input.patterns }) + }, + }, + ) + + expect(req).toEqual([{ permission: "worktree_exit", patterns: ["/tmp/sandbox"] }]) + expect(remove).toHaveBeenCalledWith({ directory: "/tmp/sandbox" }) + expect(result.metadata).toMatchObject({ directory: "/tmp/sandbox", removed: true }) + }) + + test("registers enter and exit worktree tools", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("worktree_enter") + expect(ids).toContain("worktree_exit") + }, + }) + }) +}) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts new file mode 100644 index 000000000000..55876cf43249 --- /dev/null +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -0,0 +1,486 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { Trigger } from "../../src/trigger" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +beforeEach(async () => { + await resetDatabase() +}) + +afterEach(async () => { + mock.restore() + await Instance.disposeAll() +}) + +describe("trigger service", () => { + test("creates triggers per instance and fires them later", async () => { + await using a = await tmpdir({ git: true }) + await using b = await tmpdir({ git: true }) + + await Instance.provide({ + directory: a.path, + fn: async () => { + const item = await Trigger.create({ interval: 20 }) + const list = await Trigger.list() + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ + id: item.id, + schedule: { interval: 20 }, + enabled: true, + runs: 0, + }) + + await Bun.sleep(80) + + const next = (await Trigger.list())[0] + expect(next?.runs).toBeGreaterThan(0) + expect(next?.time.last).toBeGreaterThanOrEqual(next!.time.created) + }, + }) + + await Instance.provide({ + directory: b.path, + fn: async () => { + expect(await Trigger.list()).toEqual([]) + }, + }) + }) + + test("disabled trigger does not fire until re-enabled", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ interval: 20 }) + + expect((await Trigger.get(item.id)).enabled).toBe(true) + + const off = await Trigger.disable(item.id) + expect(off.enabled).toBe(false) + + await Bun.sleep(80) + + const idle = await Trigger.get(item.id) + expect(idle.enabled).toBe(false) + expect(idle.runs).toBe(0) + + const on = await Trigger.enable(item.id) + expect(on.enabled).toBe(true) + + await Bun.sleep(80) + + const next = await Trigger.get(item.id) + expect(next.enabled).toBe(true) + expect(next.runs).toBeGreaterThan(0) + }, + }) + }) + + test("deleted trigger no longer lists or fires", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ interval: 20 }) + await Trigger.remove(item.id) + + expect(await Trigger.list()).toEqual([]) + + await Bun.sleep(80) + + expect(await Trigger.list()).toEqual([]) + }, + }) + }) + + test("loads persisted triggers after instance disposal", async () => { + await using tmp = await tmpdir({ git: true }) + + const created = await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ interval: 5_000 }) + await Trigger.fire(item.id) + return await Trigger.disable(item.id) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => Instance.dispose(), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Trigger.list()).toEqual([ + { + ...created, + }, + ]) + }, + }) + }) + + test("loads persisted webhook secret after instance disposal", async () => { + await using tmp = await tmpdir({ git: true }) + + const created = await Instance.provide({ + directory: tmp.path, + fn: async () => { + return await Trigger.create({ + interval: 5_000, + webhook_secret: "topsecret", + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => Instance.dispose(), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Trigger.get(created.id)).toMatchObject({ + id: created.id, + webhook_secret: "topsecret", + }) + }, + }) + }) + + test("loads persisted one-shot trigger after instance disposal", async () => { + await using tmp = await tmpdir({ git: true }) + + const at = Date.now() + 5_000 + const created = await Instance.provide({ + directory: tmp.path, + fn: async () => { + return await Trigger.create({ + schedule: { + type: "once", + at, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => Instance.dispose(), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Trigger.get(created.id)).toEqual(created) + }, + }) + }) + + test("fires one-shot trigger once when due", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const at = Date.now() + 20 + const item = await Trigger.create({ + schedule: { + type: "once", + at, + }, + }) + + await Bun.sleep(80) + + expect(await Trigger.get(item.id)).toMatchObject({ + id: item.id, + schedule: { + type: "once", + at, + }, + runs: 1, + last: { + source: "schedule", + status: "success", + time: expect.any(Number), + }, + }) + }, + }) + }) + + test("does not repeat one-shot trigger after firing", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ + schedule: { + type: "once", + at: Date.now() + 20, + }, + }) + + await Bun.sleep(80) + const first = await Trigger.get(item.id) + + await Bun.sleep(80) + const next = await Trigger.get(item.id) + + expect(first.runs).toBe(1) + expect(next.runs).toBe(1) + expect(next.last).toEqual(first.last) + }, + }) + }) + + test("fires command action for an idle session", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const command = spyOn(SessionPrompt, "command").mockResolvedValue( + {} as Awaited>, + ) + + await Trigger.create({ + interval: 20, + action: { + type: "command", + sessionID: session.id, + command: "init", + arguments: "--help", + }, + }) + + await Bun.sleep(80) + + const next = (await Trigger.list())[0] + + expect(command).toHaveBeenCalledWith({ + sessionID: session.id, + command: "init", + arguments: "--help", + }) + expect(next?.last).toMatchObject({ + source: "schedule", + status: "success", + time: expect.any(Number), + }) + }, + }) + }) + + test("skips command action for a busy session", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const command = spyOn(SessionPrompt, "command").mockResolvedValue( + {} as Awaited>, + ) + await SessionStatus.set(session.id, { type: "busy" }) + + await Trigger.create({ + interval: 20, + action: { + type: "command", + sessionID: session.id, + command: "init", + arguments: "--help", + }, + }) + + await Bun.sleep(80) + + const next = (await Trigger.list())[0] + expect(command).not.toHaveBeenCalled() + expect(next?.last).toMatchObject({ + source: "schedule", + status: "skipped", + time: expect.any(Number), + }) + }, + }) + }) + + test("records failed action error", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = new Error("boom") + spyOn(SessionPrompt, "command").mockRejectedValue(err) + + const item = await Trigger.create({ + interval: 5_000, + action: { + type: "command", + sessionID: session.id, + command: "init", + }, + }) + + const next = await Trigger.fire(item.id) + + expect(next.last).toMatchObject({ + source: "manual", + status: "failed", + error: "boom", + time: expect.any(Number), + }) + }, + }) + }) + + test("fires webhook action", async () => { + await using tmp = await tmpdir({ git: true }) + + const fetch = globalThis.fetch + globalThis.fetch = mock(async () => new Response(null, { status: 204 })) as unknown as typeof fetch + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ + interval: 5_000, + action: { + type: "webhook", + url: "https://example.test/hook", + method: "POST", + headers: { + authorization: "Bearer token", + }, + body: '{"ok":true}', + }, + } as unknown as Parameters[0]) + + const next = await Trigger.fire(item.id) + + expect(globalThis.fetch).toHaveBeenCalledWith("https://example.test/hook", { + method: "POST", + headers: { + authorization: "Bearer token", + }, + body: '{"ok":true}', + }) + expect(next.last).toMatchObject({ + source: "manual", + status: "success", + time: expect.any(Number), + }) + }, + }) + } finally { + globalThis.fetch = fetch + } + }) + + test("records failed webhook status", async () => { + await using tmp = await tmpdir({ git: true }) + + const fetch = globalThis.fetch + globalThis.fetch = mock(async () => new Response("denied", { status: 403 })) as unknown as typeof fetch + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const item = await Trigger.create({ + interval: 5_000, + action: { + type: "webhook", + url: "https://example.test/hook", + }, + } as unknown as Parameters[0]) + + const next = await Trigger.fire(item.id) + + expect(next.last).toMatchObject({ + source: "manual", + status: "failed", + error: "HTTP 403: denied", + time: expect.any(Number), + }) + }, + }) + } finally { + globalThis.fetch = fetch + } + }) + + test("fires trigger now", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const command = spyOn(SessionPrompt, "command").mockResolvedValue( + {} as Awaited>, + ) + const events: { triggerID: string; runs: number; at: number }[] = [] + const off = Bus.subscribe(Trigger.Event.Fired, (evt) => { + events.push(evt.properties) + }) + await Bun.sleep(10) + + const item = await Trigger.create({ + interval: 5_000, + action: { + type: "command", + sessionID: session.id, + command: "init", + arguments: "--help", + }, + }) + + const next = await Trigger.fire(item.id) + await Bun.sleep(10) + off() + + expect(command).toHaveBeenCalledWith({ + sessionID: session.id, + command: "init", + arguments: "--help", + }) + if (next.time.last === undefined) throw new Error("expected fire time") + const last = next.time.last + expect(next.runs).toBe(1) + expect(next.time.last).toBeDefined() + expect(next.time.last).toBeGreaterThanOrEqual(item.time.created) + expect(events).toEqual([ + { + triggerID: item.id, + runs: 1, + at: last, + }, + ]) + expect(next.last).toMatchObject({ + source: "manual", + status: "success", + time: expect.any(Number), + }) + }, + }) + }) +}) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d9893503fbda..445277c7c1d5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -819,6 +819,31 @@ padding-right: 12px; } } + + @media (max-width: 640px) { + [data-slot="permission-footer"] { + flex-direction: column; + align-items: stretch; + gap: 12px; + padding-top: 16px; + margin-top: 0; + + > :first-child { + display: none; + } + } + + [data-slot="permission-footer-actions"] { + flex-direction: column; + align-items: stretch; + width: 100%; + + [data-component="button"] { + width: 100%; + min-height: 44px; + } + } + } } [data-component="dock-prompt"][data-kind="question"] { @@ -1117,6 +1142,39 @@ align-items: center; gap: 8px; } + + @media (max-width: 640px) { + [data-slot="question-body"] { + gap: 12px; + } + + [data-slot="question-option"] { + padding: 14px 12px; + } + + [data-slot="question-footer"] { + flex-direction: column-reverse; + align-items: stretch; + gap: 12px; + padding-top: 16px; + margin-top: 0; + + > [data-component="button"] { + width: 100%; + min-height: 44px; + } + } + + [data-slot="question-footer-actions"] { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + + [data-component="button"] { + min-height: 44px; + } + } + } } [data-component="question-answers"] { diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e2ba2404de94..c5ed02b304f3 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -78,10 +78,75 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ----------- | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ------------- | ----- | ---------------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--workspace` | | Workspace ID to use on the remote server | +| `--session` | `-s` | Session ID to continue | + +--- + +When you are attaching to a long-running OpenCode server, `--workspace` helps you land in the right remote workspace immediately instead of browsing into it after the connection is established. + +```bash +opencode attach http://your-host:4096 --dir /srv/app --workspace ws_123 --continue +``` + +This is especially useful when you use OpenCode remotely from another laptop or from the web UI on a phone and want to continue the right session without hunting through old work. + +--- + +### trigger + +Manage lightweight scheduled triggers. + +```bash +opencode trigger [command] +``` + +Use triggers when you want OpenCode to do something later, on a schedule, or when another tool hits a webhook. + +#### list + +List the current triggers for the active project. + +```bash +opencode trigger list +``` + +#### create + +Create a repeating command trigger: + +```bash +opencode trigger create --interval 60000 --session ses_123 --command summarize --arguments "--daily" +``` + +Create a one-shot webhook trigger: + +```bash +opencode trigger create --at 1743600000000 --webhook https://example.com/hook --method POST --body '{"ok":true}' +``` + +Command actions run an OpenCode command in a session. Webhook actions send an HTTP request to another service. + +#### fire + +Run a trigger immediately without waiting for its schedule. + +```bash +opencode trigger fire +``` + +#### enable / disable / delete + +```bash +opencode trigger enable +opencode trigger disable +opencode trigger delete +``` + +Use `disable` when you want to pause a trigger without losing its configuration. --- diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 4510bd4981fe..34cd5ae27a6f 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -69,6 +69,58 @@ The [`/tui`](#tui) endpoint can be used to drive the TUI through the server. For --- +### Scheduled triggers + +OpenCode can register lightweight triggers on a running instance. Use them when you want OpenCode to do work later, on a schedule, or when another system hits a webhook. + +Today, triggers support two scheduling modes: + +- `interval` — run repeatedly after a fixed number of milliseconds +- `once` — run once at a specific Unix millisecond timestamp + +And they support two action types: + +- `command` — run an OpenCode command in a session +- `webhook` — send an HTTP request to another service + +For example, this can be used to wake up OpenCode every morning, run a recurring command against an existing session, or forward a scheduled event into an external automation system. + +If you prefer CLI management instead of raw API calls, see [CLI](/docs/cli#trigger). + +--- + +### Remote control from browser or phone + +Because OpenCode uses a client/server architecture, you can keep a server running on one machine and control it from another browser, another computer, or a phone. + +Typical setup: + +```bash +export OPENCODE_SERVER_PASSWORD='choose-a-strong-password' +opencode web --hostname 0.0.0.0 --port 4096 +``` + +Then: + +- open the web UI from another device +- or attach a TUI from another computer with `opencode attach` + +```bash +opencode attach http://your-host:4096 --dir /srv/app --workspace ws_123 --continue +``` + +The current remote-control flow is designed to make blocked sessions easier to recover when you are away from the terminal: + +- workspace-aware remote attach +- session continue and fork flows +- an awaiting-input inbox in the web app +- mobile session attention states and blocked-session indicators +- browser notification, title, and badge attention when OpenCode needs input + +This makes it practical to leave OpenCode running on one machine and answer questions later from a browser on your phone or another device. + +--- + ## Spec The server publishes an OpenAPI 3.1 spec that can be viewed at: @@ -189,6 +241,21 @@ The opencode server exposes the following APIs. --- +### Triggers + +| Method | Path | Description | Response | +| -------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | +| `GET` | `/trigger` | List triggers for the current instance | Trigger[] | +| `POST` | `/trigger` | Create a trigger | body: trigger input, returns Trigger | +| `GET` | `/trigger/:id` | Get a single trigger | Trigger | +| `POST` | `/trigger/:id/fire` | Fire a trigger immediately | Trigger | +| `POST` | `/trigger/:id/fire/webhook` | Fire a trigger through its webhook endpoint | Trigger | +| `POST` | `/trigger/:id/enable` | Enable a trigger | Trigger | +| `POST` | `/trigger/:id/disable` | Disable a trigger | Trigger | +| `DELETE` | `/trigger/:id` | Delete a trigger | `{ success: true }` | + +--- + ### Files | Method | Path | Description | Response |