From 86596b7fa7692764814958013df370e767308008 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 09:21:50 -0700 Subject: [PATCH 01/40] chore: add Claude Code parity roadmap --- ROADMAP.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000000..b3e71fbd52a4 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# OpenCode Feature Roadmap (Claude Code Parity) + +This roadmap outlines the 11 major features required to bring OpenCode up to parity with the leaked Claude Code capabilities. Features will be implemented in order. + +## Phase 1: Core Agentic Capabilities +- [ ] **1. Native Desktop Control (Computer Use Tool)** + Integrate `@nut-tree/nut-js` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. +- [ ] **2. Headless Browser Automation (WebBrowserTool)** + Integrate Playwright to allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). +- [ ] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** + Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. +- [ ] **4. Long-Term Semantic Memory (SessionMemory)** + Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. +- [ ] **5. Strict Zod-Based Permission Gates (PermissionRouter)** + Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. + +## Phase 2: Experimental & Background Systems +- [ ] **6. Buddy (Virtual Pet)** + Add a React/Ink component for an ASCII companion (duck, dragon, axolotl) that sits beside input and reacts dynamically to LLM confidence scores or bash success/failure rates. +- [ ] **7. Auto-Dream & AFK Mode** + Add an idle timer that spawns a background worker to consolidate session memory and review past context without burning active tokens while the user is away. +- [ ] **8. KAIROS & Daemon Mode (Proactive Agent)** + Extend the existing `--serve` headless mode into a true daemon that uses `cron` to wake up, fetch GitHub PRs, and proactively open review sessions. +- [ ] **9. Voice Mode** + Integrate local Whisper (or API) for speech-to-text input, and TTS for terminal audio output. +- [ ] **10. Bridge / Remote Control & Peer Discovery** + Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. +- [ ] **11. Specialized Modes (/advisor, /bughunter, /teleport)** + Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. From 943fbe01b2a5903275aae6021c2d9665ee75f7dc Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 09:28:33 -0700 Subject: [PATCH 02/40] Feature 1: Native Desktop Control (Computer Use Tool) Implements Feature 1 from ROADMAP.md - Native Desktop Control. - Install @nut-tree-fork/nut-js for desktop automation - Create new desktop.ts tool with support for screenshots, mouse movement, clicking, and typing - Add tool description in desktop.txt - Register DesktopTool in the tool registry --- bun.lock | 229 ++++++++++++++++++++++++- packages/opencode/package.json | 1 + packages/opencode/src/tool/desktop.ts | 176 +++++++++++++++++++ packages/opencode/src/tool/desktop.txt | 50 ++++++ packages/opencode/src/tool/registry.ts | 2 + 5 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/tool/desktop.ts create mode 100644 packages/opencode/src/tool/desktop.txt diff --git a/bun.lock b/bun.lock index f4523f96b16a..0a4919fe7f16 100644 --- a/bun.lock +++ b/bun.lock @@ -330,6 +330,7 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", + "@nut-tree-fork/nut-js": "4.2.6", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -1201,12 +1202,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 +1248,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 +1266,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 +1402,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 +1958,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 +2346,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 +2466,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 +2504,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 +2562,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=="], @@ -2739,6 +2792,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 +3008,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=="], @@ -3041,7 +3098,7 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -3057,6 +3114,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 +3322,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 +3386,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 +3506,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 +3718,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=="], @@ -3721,6 +3788,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 +3798,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 +3930,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 +3976,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=="], @@ -4077,6 +4152,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 +4454,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 +4520,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 +4784,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 +4824,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 +4834,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 +5140,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 +5168,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 +5184,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 +5254,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=="], @@ -5467,6 +5616,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 +6064,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 +6186,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=="], @@ -6399,6 +6596,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 +6652,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 +6820,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 +6872,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/opencode/package.json b/packages/opencode/package.json index 7bf95d66ccd6..6cd70c6fd9f7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -94,6 +94,7 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", + "@nut-tree-fork/nut-js": "4.2.6", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts new file mode 100644 index 000000000000..dab25cccffc1 --- /dev/null +++ b/packages/opencode/src/tool/desktop.ts @@ -0,0 +1,176 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./desktop.txt" +import { Log } from "../util/log" +import path from "path" +import os from "os" +import fs from "fs/promises" + +const log = Log.create({ service: "desktop-tool" }) + +async function loadNutJs() { + try { + return await import("@nut-tree-fork/nut-js") + } catch (error) { + log.error("Failed to load @nut-tree-fork/nut-js", { error }) + throw new Error("Desktop automation library not available. Please ensure @nut-tree-fork/nut-js is installed.") + } +} + +async function tempFile() { + const tmpDir = os.tmpdir() + const filename = `opencode-desktop-${Date.now()}.png` + return path.join(tmpDir, filename) +} + +export const DesktopTool = Tool.define("desktop", async () => { + await loadNutJs() + + return { + description: DESCRIPTION, + parameters: z.object({ + action: z + .enum(["screenshot", "mouse_move", "mouse_click", "type"]) + .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"), + }), + async execute(params, ctx) { + const nut = await loadNutJs() + + switch (params.action) { + case "screenshot": { + log.info("Taking screenshot", { region: params.region }) + + const tmpFile = await tempFile() + + if (params.region) { + const region = new nut.Region(params.region.x, params.region.y, params.region.width, params.region.height) + await nut.screen.captureRegion(tmpFile, region) + } else { + await nut.screen.capture(tmpFile) + } + + const imageBuffer = await fs.readFile(tmpFile) + const base64Data = imageBuffer.toString("base64") + const width = params.region ? params.region.width : await nut.screen.width() + const height = params.region ? params.region.height : await nut.screen.height() + + await fs.unlink(tmpFile) + + 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, + } + } + + 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..efc1c6557615 --- /dev/null +++ b/packages/opencode/src/tool/desktop.txt @@ -0,0 +1,50 @@ +Native desktop automation tool that enables controlling the computer outside the terminal. Supports taking screenshots, moving the mouse, clicking, and typing text. 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. + +## 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!"} +``` + +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 a8349e2c19bd..d9e88258873b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -32,6 +32,7 @@ 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" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -129,6 +130,7 @@ export namespace ToolRegistry { TodoWriteTool, WebSearchTool, CodeSearchTool, + DesktopTool, SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), From 7ccc621d276dea58651ea5b5a6adfed02cc34eee Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 09:59:30 -0700 Subject: [PATCH 03/40] feat: add Headless Browser Automation tool (WebBrowserTool) --- bun.lock | 11 +- packages/opencode/package.json | 1 + packages/opencode/src/tool/browser.ts | 135 +++++++++++++++++++++++++ packages/opencode/src/tool/browser.txt | 16 +++ packages/opencode/src/tool/registry.ts | 2 + 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/tool/browser.ts create mode 100644 packages/opencode/src/tool/browser.txt diff --git a/bun.lock b/bun.lock index 0a4919fe7f16..590e43eafd95 100644 --- a/bun.lock +++ b/bun.lock @@ -374,6 +374,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:", @@ -4006,9 +4007,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=="], @@ -5342,6 +5343,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=="], @@ -6294,6 +6297,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=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6cd70c6fd9f7..be656617b5a4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -138,6 +138,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/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/registry.ts b/packages/opencode/src/tool/registry.ts index d9e88258873b..95be7fb4aadd 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -33,6 +33,7 @@ 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" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -131,6 +132,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, DesktopTool, + BrowserTool, SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), From e7ddba585599ae0069f673d60f6261b4b5c2aecc Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 10:10:29 -0700 Subject: [PATCH 04/40] Implement SpawnMultiAgentTool (Swarm) for parallel sub-agent execution --- packages/opencode/src/config/config.ts | 7 + packages/opencode/src/tool/registry.ts | 2 + packages/opencode/src/tool/swarm.ts | 236 +++++++++++++++++++++++++ packages/opencode/src/tool/swarm.txt | 35 ++++ 4 files changed, 280 insertions(+) create mode 100644 packages/opencode/src/tool/swarm.ts create mode 100644 packages/opencode/src/tool/swarm.txt diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9e56c980fbeb..4074bf2f5e06 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/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 95be7fb4aadd..f818cbc3677d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -34,6 +34,7 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { DesktopTool } from "./desktop" import { BrowserTool } from "./browser" +import { SwarmTool } from "./swarm" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -133,6 +134,7 @@ export namespace ToolRegistry { CodeSearchTool, DesktopTool, BrowserTool, + SwarmTool, SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), 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 From bdc3f9f808545db06c43ec53a1692db22e257bdc Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 10:56:43 -0700 Subject: [PATCH 05/40] feat: implement Long-Term Semantic Memory (SessionMemory) - Add SQLite tables for user preferences, project architecture rules, and API keys - Create MemoryRepo service with CRUD operations for all three data types - Add proper indexes for efficient querying - Generate Drizzle migration for session_memory feature - Add comprehensive tests for memory functionality This implements Feature 4 from ROADMAP.md for persisting user preferences, project architecture rules, and API keys across different terminal sessions. --- .../migration.sql | 37 + .../snapshot.json | 1681 +++++++++++++++++ packages/opencode/src/memory/index.ts | 14 + packages/opencode/src/memory/memory.sql.ts | 51 + packages/opencode/src/memory/repo.ts | 234 +++ packages/opencode/src/memory/schema.ts | 72 + packages/opencode/test/memory/memory.test.ts | 78 + 7 files changed, 2167 insertions(+) create mode 100644 packages/opencode/migration/20260331175554_session_memory/migration.sql create mode 100644 packages/opencode/migration/20260331175554_session_memory/snapshot.json create mode 100644 packages/opencode/src/memory/index.ts create mode 100644 packages/opencode/src/memory/memory.sql.ts create mode 100644 packages/opencode/src/memory/repo.ts create mode 100644 packages/opencode/src/memory/schema.ts create mode 100644 packages/opencode/test/memory/memory.test.ts 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/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/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) + }) +}) From 3573ca064c196cd06452a38e94ed5c0a9485bb34 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 10:56:43 -0700 Subject: [PATCH 06/40] feat: implement Long-Term Semantic Memory (SessionMemory) - Add SQLite tables for user preferences, project architecture rules, and API keys - Create MemoryRepo service with CRUD operations for all three data types - Add proper indexes for efficient querying - Generate Drizzle migration for session_memory feature - Add comprehensive tests for memory functionality This implements Feature 4 from ROADMAP.md for persisting user preferences, project architecture rules, and API keys across different terminal sessions. --- .../migration.sql | 37 + .../snapshot.json | 1681 +++++++++++++++++ packages/opencode/src/memory/index.ts | 14 + packages/opencode/src/memory/memory.sql.ts | 51 + packages/opencode/src/memory/repo.ts | 234 +++ packages/opencode/src/memory/schema.ts | 72 + packages/opencode/test/memory/memory.test.ts | 78 + 7 files changed, 2167 insertions(+) create mode 100644 packages/opencode/migration/20260331175554_session_memory/migration.sql create mode 100644 packages/opencode/migration/20260331175554_session_memory/snapshot.json create mode 100644 packages/opencode/src/memory/index.ts create mode 100644 packages/opencode/src/memory/memory.sql.ts create mode 100644 packages/opencode/src/memory/repo.ts create mode 100644 packages/opencode/src/memory/schema.ts create mode 100644 packages/opencode/test/memory/memory.test.ts 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/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/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) + }) +}) From 2c23726cd57eefeb7175ee6cfe28d616093cb37e Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 11:26:07 -0700 Subject: [PATCH 07/40] feat: implement strict Zod-based permission router with secondary LLM classifier --- .../opencode/src/permission/classifier.ts | 190 ++++++++++++++++++ packages/opencode/src/permission/index.ts | 4 + .../opencode/src/permission/router-service.ts | 83 ++++++++ packages/opencode/src/permission/router.ts | 181 +++++++++++++++++ .../opencode/test/permission/router.test.ts | 130 ++++++++++++ 5 files changed, 588 insertions(+) create mode 100644 packages/opencode/src/permission/classifier.ts create mode 100644 packages/opencode/src/permission/router-service.ts create mode 100644 packages/opencode/src/permission/router.ts create mode 100644 packages/opencode/test/permission/router.test.ts 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/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") + }) +}) From 3c648513f41b9d853f97f51d988bd02797f95141 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:16:48 -0700 Subject: [PATCH 08/40] docs: update roadmap with phase 3 advanced features and mark phase 1 complete --- ROADMAP.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b3e71fbd52a4..815faa77a3dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,15 +3,15 @@ This roadmap outlines the 11 major features required to bring OpenCode up to parity with the leaked Claude Code capabilities. Features will be implemented in order. ## Phase 1: Core Agentic Capabilities -- [ ] **1. Native Desktop Control (Computer Use Tool)** +- [x] **1. Native Desktop Control (Computer Use Tool)** Integrate `@nut-tree/nut-js` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. -- [ ] **2. Headless Browser Automation (WebBrowserTool)** +- [x] **2. Headless Browser Automation (WebBrowserTool)** Integrate Playwright to allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). -- [ ] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** +- [x] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. -- [ ] **4. Long-Term Semantic Memory (SessionMemory)** +- [x] **4. Long-Term Semantic Memory (SessionMemory)** Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. -- [ ] **5. Strict Zod-Based Permission Gates (PermissionRouter)** +- [x] **5. Strict Zod-Based Permission Gates (PermissionRouter)** Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. ## Phase 2: Experimental & Background Systems @@ -27,3 +27,17 @@ This roadmap outlines the 11 major features required to bring OpenCode up to par Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. - [ ] **11. Specialized Modes (/advisor, /bughunter, /teleport)** Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. + +## Phase 3: Advanced Workflows & Context Management +- [ ] **12. Jupyter Notebook Integration (NotebookEditTool)** + Add a dedicated tool specifically for reading, manipulating, and executing Jupyter Notebook (`.ipynb`) cells directly as JSON without breaking the file structure. +- [ ] **13. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** + Allow the agent to autonomously spawn a temporary Git Worktree (an isolated clone of the repo), do experimental coding there, test it, and only merge it back if it works. +- [ ] **14. Background Task Orchestration (TaskCreateTool, TaskUpdateTool, TaskOutputTool)** + Allow the agent to kick off long-running terminal commands (like `npm run build` or `pytest`), push them to the background, and periodically check their status without blocking the chat UI. +- [ ] **15. Context Window Management (SnipTool & BriefTool)** + Implement `SnipTool` to autonomously permanently delete useless messages from the middle of the context window, and `BriefTool` to replace debugging loops with a 2-sentence summary. +- [ ] **16. Scheduled & Remote Triggers (ScheduleCronTool & RemoteTriggerTool)** + Allow the agent to create cron jobs to wake itself up, or expose a local webhook so external services can ping it to start working. +- [ ] **17. System Monitoring (MonitorTool)** + Allow the agent to read CPU, memory usage, and active processes to diagnose system crashes or performance issues. From 2b3d3dbfe1ac856da25ad8a757a1fed4b3e886b9 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:17:20 -0700 Subject: [PATCH 09/40] fix: add chromium-bidi for Bun CLI builds --- bun.lock | 11 ++++++++++- packages/opencode/package.json | 1 + packages/opencode/test/build/chromium-bidi.test.ts | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/build/chromium-bidi.test.ts diff --git a/bun.lock b/bun.lock index 590e43eafd95..b6775fceb679 100644 --- a/bun.lock +++ b/bun.lock @@ -352,6 +352,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", @@ -2591,6 +2592,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=="], @@ -2769,6 +2772,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=="], @@ -3099,7 +3104,7 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -3743,6 +3748,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=="], @@ -5501,6 +5508,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=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index be656617b5a4..ef79cf638b1d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -116,6 +116,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", 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() + }) +}) From 7231e1fc884d7445e55b806b48e36f7fb5b57d2c Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:18:26 -0700 Subject: [PATCH 10/40] docs: add comprehensive feature roadmap for claude code parity --- ROADMAP.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000000..815faa77a3dd --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,43 @@ +# OpenCode Feature Roadmap (Claude Code Parity) + +This roadmap outlines the 11 major features required to bring OpenCode up to parity with the leaked Claude Code capabilities. Features will be implemented in order. + +## Phase 1: Core Agentic Capabilities +- [x] **1. Native Desktop Control (Computer Use Tool)** + Integrate `@nut-tree/nut-js` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. +- [x] **2. Headless Browser Automation (WebBrowserTool)** + Integrate Playwright to allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). +- [x] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** + Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. +- [x] **4. Long-Term Semantic Memory (SessionMemory)** + Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. +- [x] **5. Strict Zod-Based Permission Gates (PermissionRouter)** + Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. + +## Phase 2: Experimental & Background Systems +- [ ] **6. Buddy (Virtual Pet)** + Add a React/Ink component for an ASCII companion (duck, dragon, axolotl) that sits beside input and reacts dynamically to LLM confidence scores or bash success/failure rates. +- [ ] **7. Auto-Dream & AFK Mode** + Add an idle timer that spawns a background worker to consolidate session memory and review past context without burning active tokens while the user is away. +- [ ] **8. KAIROS & Daemon Mode (Proactive Agent)** + Extend the existing `--serve` headless mode into a true daemon that uses `cron` to wake up, fetch GitHub PRs, and proactively open review sessions. +- [ ] **9. Voice Mode** + Integrate local Whisper (or API) for speech-to-text input, and TTS for terminal audio output. +- [ ] **10. Bridge / Remote Control & Peer Discovery** + Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. +- [ ] **11. Specialized Modes (/advisor, /bughunter, /teleport)** + Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. + +## Phase 3: Advanced Workflows & Context Management +- [ ] **12. Jupyter Notebook Integration (NotebookEditTool)** + Add a dedicated tool specifically for reading, manipulating, and executing Jupyter Notebook (`.ipynb`) cells directly as JSON without breaking the file structure. +- [ ] **13. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** + Allow the agent to autonomously spawn a temporary Git Worktree (an isolated clone of the repo), do experimental coding there, test it, and only merge it back if it works. +- [ ] **14. Background Task Orchestration (TaskCreateTool, TaskUpdateTool, TaskOutputTool)** + Allow the agent to kick off long-running terminal commands (like `npm run build` or `pytest`), push them to the background, and periodically check their status without blocking the chat UI. +- [ ] **15. Context Window Management (SnipTool & BriefTool)** + Implement `SnipTool` to autonomously permanently delete useless messages from the middle of the context window, and `BriefTool` to replace debugging loops with a 2-sentence summary. +- [ ] **16. Scheduled & Remote Triggers (ScheduleCronTool & RemoteTriggerTool)** + Allow the agent to create cron jobs to wake itself up, or expose a local webhook so external services can ping it to start working. +- [ ] **17. System Monitoring (MonitorTool)** + Allow the agent to read CPU, memory usage, and active processes to diagnose system crashes or performance issues. From 6ace249bdd8fc6e1073f1ac8446ea6f9906863c5 Mon Sep 17 00:00:00 2001 From: nicholasdominici Date: Tue, 31 Mar 2026 12:25:58 -0700 Subject: [PATCH 11/40] fix: resolve native addon dependencies for Desktop tool Add transitive @nut-tree-fork dependencies (libnut, libnut-darwin, shared) to package.json so native .node bindings resolve correctly at runtime. Improve error handling in desktop.ts to surface the actual underlying error instead of a generic message, making debugging easier. --- bun.lock | 14 +++++++++++++- packages/opencode/package.json | 3 +++ packages/opencode/src/tool/desktop.ts | 25 ++++++++++++++++++++----- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 590e43eafd95..16003987fe43 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "chromium-bidi": "15.0.0", "typescript": "catalog:", }, "devDependencies": { @@ -330,7 +331,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:", @@ -2591,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=="], @@ -2769,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=="], @@ -3099,7 +3107,7 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -3743,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=="], @@ -5501,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=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index be656617b5a4..ce2423a9589c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -94,7 +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:", diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts index dab25cccffc1..81bfe17d20be 100644 --- a/packages/opencode/src/tool/desktop.ts +++ b/packages/opencode/src/tool/desktop.ts @@ -8,12 +8,29 @@ import fs from "fs/promises" const log = Log.create({ service: "desktop-tool" }) +let nutCache: any = undefined +let nutFailed = false +let nutError: Error | undefined = undefined + 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 { - return await import("@nut-tree-fork/nut-js") + nutCache = await import("@nut-tree-fork/nut-js") + return nutCache } catch (error) { - log.error("Failed to load @nut-tree-fork/nut-js", { error }) - throw new Error("Desktop automation library not available. Please ensure @nut-tree-fork/nut-js is installed.") + 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.`) } } @@ -24,8 +41,6 @@ async function tempFile() { } export const DesktopTool = Tool.define("desktop", async () => { - await loadNutJs() - return { description: DESCRIPTION, parameters: z.object({ From 4fb2631bb4fcfc59e3c4a3bc2ed6a8c7609cf2de Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:44:10 -0700 Subject: [PATCH 12/40] fix: resolve @nut-tree-fork/nut-js from compiled Bun binary The Desktop tool failed to load @nut-tree-fork/nut-js when running from a compiled Bun binary because the bindings package couldn't resolve the native addon from the virtual /$bunfs path. Changes: - Add desktop.runtime.ts helper that imports nut-js from real filesystem - Update desktop.ts to delegate to helper when running from /$bunfs - Update build.ts to emit desktop.runtime.mjs next to the binary - Add regression test for compiled vs source runtime resolution Fixes module resolution for native dependencies in compiled binaries. --- packages/opencode/script/build.ts | 2 ++ packages/opencode/src/tool/desktop.runtime.ts | 4 +++ packages/opencode/src/tool/desktop.ts | 12 ++++++- packages/opencode/test/tool/desktop.test.ts | 31 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/tool/desktop.runtime.ts create mode 100644 packages/opencode/test/tool/desktop.test.ts 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/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 index 81bfe17d20be..22308720f08e 100644 --- a/packages/opencode/src/tool/desktop.ts +++ b/packages/opencode/src/tool/desktop.ts @@ -4,7 +4,9 @@ import DESCRIPTION from "./desktop.txt" import { Log } from "../util/log" import path from "path" import os from "os" +import fsSync from "fs" import fs from "fs/promises" +import { pathToFileURL } from "url" const log = Log.create({ service: "desktop-tool" }) @@ -12,6 +14,14 @@ 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) { @@ -19,7 +29,7 @@ async function loadNutJs() { throw new Error(`Desktop automation library not available${details} Please ensure @nut-tree-fork/nut-js is installed.`) } try { - nutCache = await import("@nut-tree-fork/nut-js") + nutCache = await import(resolveNutJsImportSpecifier()) return nutCache } catch (error) { nutFailed = true diff --git a/packages/opencode/test/tool/desktop.test.ts b/packages/opencode/test/tool/desktop.test.ts new file mode 100644 index 000000000000..c3c662cc12be --- /dev/null +++ b/packages/opencode/test/tool/desktop.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import { resolveNutJsImportSpecifier } from "../../src/tool/desktop" + +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", + ) + }) +}) From b70a04d89a21ac28de60f1d90a9e2b40a2dc2cad Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:50:23 -0700 Subject: [PATCH 13/40] fix: use grab/grabRegion for screenshots instead of capture The screen.capture() and captureRegion() methods don't write to the path we expect - they return the actual saved path. The code was trying to read from a temp file path that never had the screenshot data. Fix: Use grab()/grabRegion() which return Image objects, then call toPNG() to get the buffer directly. This eliminates temp file issues. Also removes unused imports (os, fs/promises) and tempFile() function. --- packages/opencode/src/tool/desktop.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts index 22308720f08e..83263c5fe337 100644 --- a/packages/opencode/src/tool/desktop.ts +++ b/packages/opencode/src/tool/desktop.ts @@ -3,9 +3,7 @@ import { Tool } from "./tool" import DESCRIPTION from "./desktop.txt" import { Log } from "../util/log" import path from "path" -import os from "os" import fsSync from "fs" -import fs from "fs/promises" import { pathToFileURL } from "url" const log = Log.create({ service: "desktop-tool" }) @@ -44,12 +42,6 @@ async function loadNutJs() { } } -async function tempFile() { - const tmpDir = os.tmpdir() - const filename = `opencode-desktop-${Date.now()}.png` - return path.join(tmpDir, filename) -} - export const DesktopTool = Tool.define("desktop", async () => { return { description: DESCRIPTION, @@ -86,21 +78,23 @@ export const DesktopTool = Tool.define("desktop", async () => { case "screenshot": { log.info("Taking screenshot", { region: params.region }) - const tmpFile = await tempFile() + 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) - await nut.screen.captureRegion(tmpFile, region) + image = await nut.screen.grabRegion(region) + width = params.region.width + height = params.region.height } else { - await nut.screen.capture(tmpFile) + image = await nut.screen.grab() + width = await nut.screen.width() + height = await nut.screen.height() } - const imageBuffer = await fs.readFile(tmpFile) + const imageBuffer = await image.toPNG() const base64Data = imageBuffer.toString("base64") - const width = params.region ? params.region.width : await nut.screen.width() - const height = params.region ? params.region.height : await nut.screen.height() - - await fs.unlink(tmpFile) return { title: params.region ? "Partial screenshot captured" : "Screenshot captured", From faa35aa28c4594bf7ce10d8c45d7a6221c8a9ac6 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 12:59:12 -0700 Subject: [PATCH 14/40] Add keyboard shortcut support to desktop tool --- packages/opencode/src/tool/desktop.ts | 71 +++++++++++++++++++-- packages/opencode/src/tool/desktop.txt | 15 ++++- packages/opencode/test/tool/desktop.test.ts | 55 +++++++++++++++- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts index 83263c5fe337..67c3359a2717 100644 --- a/packages/opencode/src/tool/desktop.ts +++ b/packages/opencode/src/tool/desktop.ts @@ -8,6 +8,30 @@ import { pathToFileURL } from "url" 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 @@ -24,7 +48,9 @@ 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.`) + throw new Error( + `Desktop automation library not available${details} Please ensure @nut-tree-fork/nut-js is installed.`, + ) } try { nutCache = await import(resolveNutJsImportSpecifier()) @@ -32,22 +58,35 @@ async function loadNutJs() { } 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", { + 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 + arch: process.arch, }) - throw new Error(`Desktop automation library not available: ${nutError.message}. Please ensure @nut-tree-fork/nut-js is installed.`) + 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"]) + .enum(["screenshot", "mouse_move", "mouse_click", "type", "key_click"]) .describe("The desktop automation action to perform"), region: z .object({ @@ -70,6 +109,8 @@ export const DesktopTool = Tool.define("desktop", async () => { .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() @@ -187,6 +228,26 @@ export const DesktopTool = Tool.define("desktop", async () => { } } + 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 index efc1c6557615..093fb1882da3 100644 --- a/packages/opencode/src/tool/desktop.txt +++ b/packages/opencode/src/tool/desktop.txt @@ -1,4 +1,4 @@ -Native desktop automation tool that enables controlling the computer outside the terminal. Supports taking screenshots, moving the mouse, clicking, and typing text. This replicates Anthropic's Computer Use tool functionality. +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 @@ -14,6 +14,9 @@ Perform mouse clicks (left, right, or middle button) at the current cursor posit ### 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: @@ -36,6 +39,16 @@ 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} diff --git a/packages/opencode/test/tool/desktop.test.ts b/packages/opencode/test/tool/desktop.test.ts index c3c662cc12be..5f3249274fd7 100644 --- a/packages/opencode/test/tool/desktop.test.ts +++ b/packages/opencode/test/tool/desktop.test.ts @@ -1,9 +1,42 @@ -import { describe, expect, test } from "bun:test" +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 { resolveNutJsImportSpecifier } from "../../src/tool/desktop" +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) + }, + }, + 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 () => { @@ -29,3 +62,21 @@ describe("resolveNutJsImportSpecifier", () => { ) }) }) + +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") + }) +}) From 92f7217ff5f69e3055031493f01b5cd469f951cf Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 13:13:17 -0700 Subject: [PATCH 15/40] fix: use imageToJimp for screenshot PNG conversion --- packages/opencode/src/tool/desktop.ts | 3 ++- packages/opencode/test/tool/desktop.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/desktop.ts b/packages/opencode/src/tool/desktop.ts index 67c3359a2717..06151028b54a 100644 --- a/packages/opencode/src/tool/desktop.ts +++ b/packages/opencode/src/tool/desktop.ts @@ -5,6 +5,7 @@ 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" }) @@ -134,7 +135,7 @@ export const DesktopTool = Tool.define("desktop", async () => { height = await nut.screen.height() } - const imageBuffer = await image.toPNG() + const imageBuffer = await imageToJimp(image).getBufferAsync("image/png") const base64Data = imageBuffer.toString("base64") return { diff --git a/packages/opencode/test/tool/desktop.test.ts b/packages/opencode/test/tool/desktop.test.ts index 5f3249274fd7..f47ca447e4bb 100644 --- a/packages/opencode/test/tool/desktop.test.ts +++ b/packages/opencode/test/tool/desktop.test.ts @@ -14,6 +14,16 @@ mock.module("@nut-tree-fork/nut-js", () => ({ 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", @@ -79,4 +89,15 @@ describe("DesktopTool", () => { 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) + }) }) From 82e923153c8c238e0cf32c16d23b374ebc47df6e Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 13:19:31 -0700 Subject: [PATCH 16/40] docs: Add local opencode override skill and remove chromium-bidi dependency --- .../skills/local-opencode-override/SKILL.md | 80 +++++++++++++++++++ bun.lock | 1 - 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 .opencode/skills/local-opencode-override/SKILL.md 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/bun.lock b/bun.lock index a3bd5d709b97..dc02dd95190f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "chromium-bidi": "15.0.0", "typescript": "catalog:", }, "devDependencies": { From d5c43156b447113e7461789bd07b15a6c95bc8f7 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 13:58:18 -0700 Subject: [PATCH 17/40] feat: add worktree sandbox tools --- packages/opencode/src/tool/registry.ts | 3 + packages/opencode/src/tool/worktree-enter.txt | 5 + packages/opencode/src/tool/worktree-exit.txt | 5 + packages/opencode/src/tool/worktree.ts | 58 +++++++++++ packages/opencode/test/tool/worktree.test.ts | 95 +++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 packages/opencode/src/tool/worktree-enter.txt create mode 100644 packages/opencode/src/tool/worktree-exit.txt create mode 100644 packages/opencode/src/tool/worktree.ts create mode 100644 packages/opencode/test/tool/worktree.test.ts diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f818cbc3677d..b485c7f3b7d2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,6 +35,7 @@ import { makeRuntime } from "@/effect/run-service" import { DesktopTool } from "./desktop" import { BrowserTool } from "./browser" import { SwarmTool } from "./swarm" +import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -135,6 +136,8 @@ export namespace ToolRegistry { DesktopTool, BrowserTool, SwarmTool, + EnterWorktreeTool, + ExitWorktreeTool, SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), 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/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") + }, + }) + }) +}) From 349776b0b588365efecb4d8e4d1e56f0b423d3f4 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 14:35:12 -0700 Subject: [PATCH 18/40] feat: add session brief tool --- packages/opencode/src/tool/brief.ts | 36 ++++++ packages/opencode/src/tool/registry.ts | 2 + packages/opencode/test/tool/brief.test.ts | 131 ++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 packages/opencode/src/tool/brief.ts create mode 100644 packages/opencode/test/tool/brief.test.ts 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/registry.ts b/packages/opencode/src/tool/registry.ts index b485c7f3b7d2..a110d67efd33 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -36,6 +36,7 @@ import { DesktopTool } from "./desktop" import { BrowserTool } from "./browser" import { SwarmTool } from "./swarm" import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree" +import { BriefTool } from "./brief" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -136,6 +137,7 @@ export namespace ToolRegistry { DesktopTool, BrowserTool, SwarmTool, + BriefTool, EnterWorktreeTool, ExitWorktreeTool, SkillTool, 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") + }, + }) + }) +}) From 9ac09471e099e17345256c7ea765db85c2819f49 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 14:50:34 -0700 Subject: [PATCH 19/40] feat: add safe session snip tool --- ROADMAP.md | 204 +++++++++++++++++--- packages/opencode/src/session/compaction.ts | 86 +++++---- packages/opencode/src/tool/registry.ts | 2 + packages/opencode/src/tool/snip.ts | 29 +++ packages/opencode/test/tool/snip.test.ts | 187 ++++++++++++++++++ 5 files changed, 450 insertions(+), 58 deletions(-) create mode 100644 packages/opencode/src/tool/snip.ts create mode 100644 packages/opencode/test/tool/snip.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 815faa77a3dd..3d1fc47470d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,41 +3,199 @@ This roadmap outlines the 11 major features required to bring OpenCode up to parity with the leaked Claude Code capabilities. Features will be implemented in order. ## Phase 1: Core Agentic Capabilities + - [x] **1. Native Desktop Control (Computer Use Tool)** - Integrate `@nut-tree/nut-js` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. + Integrate `@nut-tree/nut-js` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. - [x] **2. Headless Browser Automation (WebBrowserTool)** - Integrate Playwright to allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). + Integrate Playwright to allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). - [x] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** - Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. + Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. - [x] **4. Long-Term Semantic Memory (SessionMemory)** - Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. + Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. - [x] **5. Strict Zod-Based Permission Gates (PermissionRouter)** - Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. + Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. ## Phase 2: Experimental & Background Systems + - [ ] **6. Buddy (Virtual Pet)** - Add a React/Ink component for an ASCII companion (duck, dragon, axolotl) that sits beside input and reacts dynamically to LLM confidence scores or bash success/failure rates. + Add a React/Ink component for an ASCII companion (duck, dragon, axolotl) that sits beside input and reacts dynamically to LLM confidence scores or bash success/failure rates. - [ ] **7. Auto-Dream & AFK Mode** - Add an idle timer that spawns a background worker to consolidate session memory and review past context without burning active tokens while the user is away. + Add an idle timer that spawns a background worker to consolidate session memory and review past context without burning active tokens while the user is away. - [ ] **8. KAIROS & Daemon Mode (Proactive Agent)** - Extend the existing `--serve` headless mode into a true daemon that uses `cron` to wake up, fetch GitHub PRs, and proactively open review sessions. + Extend the existing `--serve` headless mode into a true daemon that uses `cron` to wake up, fetch GitHub PRs, and proactively open review sessions. - [ ] **9. Voice Mode** - Integrate local Whisper (or API) for speech-to-text input, and TTS for terminal audio output. + Integrate local Whisper (or API) for speech-to-text input, and TTS for terminal audio output. - [ ] **10. Bridge / Remote Control & Peer Discovery** - Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. + Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. - [ ] **11. Specialized Modes (/advisor, /bughunter, /teleport)** - Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. + Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. ## Phase 3: Advanced Workflows & Context Management -- [ ] **12. Jupyter Notebook Integration (NotebookEditTool)** - Add a dedicated tool specifically for reading, manipulating, and executing Jupyter Notebook (`.ipynb`) cells directly as JSON without breaking the file structure. -- [ ] **13. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** - Allow the agent to autonomously spawn a temporary Git Worktree (an isolated clone of the repo), do experimental coding there, test it, and only merge it back if it works. -- [ ] **14. Background Task Orchestration (TaskCreateTool, TaskUpdateTool, TaskOutputTool)** - Allow the agent to kick off long-running terminal commands (like `npm run build` or `pytest`), push them to the background, and periodically check their status without blocking the chat UI. -- [ ] **15. Context Window Management (SnipTool & BriefTool)** - Implement `SnipTool` to autonomously permanently delete useless messages from the middle of the context window, and `BriefTool` to replace debugging loops with a 2-sentence summary. -- [ ] **16. Scheduled & Remote Triggers (ScheduleCronTool & RemoteTriggerTool)** - Allow the agent to create cron jobs to wake itself up, or expose a local webhook so external services can ping it to start working. -- [ ] **17. System Monitoring (MonitorTool)** - Allow the agent to read CPU, memory usage, and active processes to diagnose system crashes or performance issues. + +- [x] **12. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** + Allow the agent to autonomously spawn a temporary Git Worktree (an isolated clone of the repo), do experimental coding there, test it, and only merge it back if it works. +- [x] **13. Background Task Orchestration (TaskCreateTool, TaskUpdateTool, TaskOutputTool)** + Allow the agent to kick off long-running terminal commands (like `npm run build` or `pytest`), push them to the background, and periodically check their status without blocking the chat UI. +- [ ] **14. Context Window Management (SnipTool & BriefTool)** + Implement `SnipTool` to autonomously permanently delete useless messages from the middle of the context window, and `BriefTool` to replace debugging loops with a 2-sentence summary. +- [ ] **15. Scheduled & Remote Triggers (ScheduleCronTool & RemoteTriggerTool)** + Allow the agent to create cron jobs to wake itself up, or expose a local webhook so external services can ping it to start working. +- [ ] **16. System Monitoring (MonitorTool)** + Allow the agent to read CPU, memory usage, and active processes to diagnose system crashes or performance issues. + +## Phase 4: Missing Tools from Claude Code (High Priority) + +Based on analysis of free-code (54 working flags, 34 failed), these tools need implementation: + +### Core Tools +- [ ] **17. BriefTool (SendUserMessage)** + A tool for the agent to send messages directly to the user with optional file attachments. Supports proactive notifications when the user is away. Different from SendMessageTool - this is for brief UI mode. +- [ ] **18. SnipTool (HistorySnip)** + Tool to permanently remove specific messages from the context window to manage token budget. Different from compaction - this is surgical deletion of useless messages from the middle of context. +- [ ] **19. WorkflowTool** + Allow users to define reusable workflow scripts that combine multiple tool calls into a single command. Support bundled workflows and user-defined ones. +- [ ] **20. TerminalCaptureTool** + Capture and analyze terminal output for debugging and context understanding. Part of terminal panel feature. + +### System & Monitoring Tools +- [ ] **21. MonitorTool** + Read system metrics (CPU, memory, disk usage, active processes) to help diagnose performance issues or system crashes. +- [ ] **22. CtxInspectTool (ContextCollapse)** + Inspect and analyze the current context window state, helping users understand what's taking up tokens. + +### Agent Team Management +- [ ] **23. TeamCreateTool / TeamDeleteTool** + Create and manage named agent teams for swarming. Allows coordinated multi-agent workflows with shared context. Part of agent swarms feature. + +### Windows Support +- [ ] **24. PowerShellTool** + Full PowerShell support on Windows with proper permission handling, read-only validation, and security controls equivalent to BashTool. + +### Scheduling & Triggers +- [ ] **25. ScheduleCronTool (CronCreate/CronDelete/CronList)** + Create, delete, and list cron jobs that can trigger agent actions at scheduled times. +- [ ] **26. RemoteTriggerTool** + Expose a local webhook endpoint that external services (GitHub, Slack, etc.) can call to trigger agent actions. + +## Phase 5: Missing Slash Commands + +### Review & Analysis Commands +- [ ] **27. /advisor** + Configure a secondary "advisor" model that reviews the primary model's outputs for quality and suggests improvements. Wraps LLM diff outputs in an evaluation loop. +- [ ] **28. /bughunter** + Dedicated bug hunting mode that uses specialized prompts and tools to find security vulnerabilities and bugs. +- [ ] **29. /teleport** + Transfer the current session to Claude Code on the web (CCR) for continued work in a browser environment. Includes remote session management. +- [ ] **30. /ultraplan** + Advanced multi-agent planning mode that uses the most powerful model (Opus) to create detailed execution plans. ~10-30 min planning session in CCR. + +### Utility Commands +- [ ] **31. /voice** + Toggle voice mode for speech-to-text input and text-to-speech output. Requires audio backend (native module or SoX fallback). +- [ ] **32. /brief** + Toggle brief mode - changes the UI to show only brief messages from the agent instead of full tool outputs. Changes default view to 'chat'. +- [ ] **33. /proactive** + Enable proactive agent behavior - the agent will wake up on schedule to check for work (PRs, issues, etc). Requires AGENT_TRIGGERS flag. +- [ ] **34. /torch** + Performance profiling and debugging command for analyzing slow operations. +- [ ] **35. /buddy** + Configure the ASCII companion/virtual pet (duck, dragon, axolotl) that reacts to session events. BUDDY flag. + +### Assistant & KAIROS Modes +- [ ] **36. /assistant** + Enter full KAIROS assistant mode - a different interaction model optimized for long-running background tasks with proactive behavior. +- [ ] **37. /brief command (KAIROS_BRIEF)** + Enable brief-only transcript layout without the full assistant stack. + +## Phase 6: Advanced Features & Infrastructure + +- [ ] **38. Context7 Integration for Libraries** + Deep documentation integration using Context7-compatible library IDs for major frameworks with up-to-date API references. +- [ ] **39. AST-Grep Integration** + Native AST-based code search and refactoring using ast-grep for pattern matching across 25+ languages. +- [ ] **40. MCP Rich Output** + Enhanced MCP tool result rendering with support for images, formatted data, and interactive elements. +- [ ] **41. Team Memory (TeamMem)** + Shared memory files for teams working on the same project, with automatic synchronization via watcher hooks. +- [ ] **42. Background Sessions (BG Sessions)** + Allow sessions to run fully in the background without a TUI, managed via CLI commands. BG_SESSIONS flag. +- [ ] **43. Commit Attribution** + Track and attribute which AI agent made specific changes in git history. COMMIT_ATTRIBUTION flag. +- [ ] **44. SSH Remote Support** + Connect to and work on remote machines via SSH with full tool support. SSH_REMOTE flag. +- [ ] **45. Direct Connect** + Peer-to-peer connection support for remote collaboration without going through cloud services. DIRECT_CONNECT flag. +- [ ] **46. Mobile Companion Support** + QR code generation and integration with mobile apps for remote control. CCR_MIRROR, CCR_AUTO_CONNECT support. +- [ ] **47. Chrome Extension Integration** + `claude-in-chrome` support for browser-based interactions and DOM manipulation. +- [ ] **48. Sandboxed Execution Mode** + Enhanced sandboxing with optional VM/isolation for untrusted code execution. +- [ ] **49. Self-Hosted Runner Support** + Deploy agents to self-hosted infrastructure for enterprise use. SELF_HOSTED_RUNNER flag. +- [ ] **50. Template System** + Project scaffolding and template system for quick project initialization. TEMPLATES flag. +- [ ] **51. Coordinator Mode** + Advanced multi-agent coordination with worker agent registry. COORDINATOR_MODE flag. +- [ ] **52. Reactive Compact** + Real-time context compaction based on usage patterns. REACTIVE_COMPACT flag. +- [ ] **53. Web Browser Tool** + Full browser automation tool distinct from the headless browser - allows user-guided browsing. +- [ ] **54. Verification Agent** + Built-in verification agent guidance in prompts for task/todo tooling. VERIFICATION_AGENT flag. +- [ ] **55. Extract Memories** + Post-query memory extraction hooks for automatic learning. EXTRACT_MEMORIES flag. +- [ ] **56. Cached Microcompact** + Cached microcompact state through query and API flows. CACHED_MICROCOMPACT flag. + +## Feature Implementation Notes + +### From free-code FEATURES.md Analysis +- **54 flags bundle cleanly** - these are user-facing or behavior-changing features +- **34 flags still fail to bundle** - these require more work to implement +- **Default build includes**: VOICE_MODE (bundles but needs OAuth + audio backend) + +### Priority Flags to Implement (Easy Reconstruction) +These have most of the surrounding code already in place: +- `AUTO_THEME` - Missing only `systemThemeWatcher.js` +- `BG_SESSIONS` - Missing only `bg.js` CLI fast-path +- `BUDDY` - Missing only `buddy/index.js` command entry +- `COMMIT_ATTRIBUTION` - Missing only `attributionHooks.js` +- `HISTORY_SNIP` - Missing only `force-snip.js` command +- `MCP_SKILLS` - Missing only `mcpSkills.js` registry layer + +### Priority Flags to Implement (Medium-Sized Gaps) +- `BYOC_ENVIRONMENT_RUNNER` - Environment runner main.js +- `CONTEXT_COLLAPSE` - CtxInspectTool implementation +- `COORDINATOR_MODE` - Coordinator worker agent system +- `DAEMON` - Worker registry for true daemon mode +- `DIRECT_CONNECT` - Parse connect URL logic +- `EXPERIMENTAL_SKILL_SEARCH` - Local skill search implementation +- `MONITOR_TOOL` - System monitoring tool +- `REACTIVE_COMPACT` - Reactive compaction service +- `REVIEW_ARTIFACT` - Hunter.js review system +- `SELF_HOSTED_RUNNER` - Self-hosted runner main.js +- `SSH_REMOTE` - SSH session creation +- `TERMINAL_PANEL` - TerminalCaptureTool +- `UDS_INBOX` - UDS messaging utilities +- `WEB_BROWSER_TOOL` - Web browser automation distinct from headless +- `WORKFLOW_SCRIPTS` - Workflow command and task implementation + +### Large Missing Subsystems +- `KAIROS` - Full assistant mode with `src/assistant/index.js` stack +- `KAIROS_DREAM` - Dream task behavior for AFK consolidation +- `PROACTIVE` - Proactive task/tool stack for daemon behavior + +## Legend + +- [x] Implemented +- [ ] Not yet implemented +- [!] Partially implemented / Experimental + +## Notes + +- Permission system uses Zod schemas with `isReadOnly`/`isDestructive` flags +- All tools should support proper TypeScript types and validation +- Slash commands follow the pattern in `src/commands.ts` +- Feature flags use `feature('FLAG_NAME')` pattern with bun:bundle +- Many experimental features are gated by GrowthBook or environment variables diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 229dff0c46de..436a6e6fe8b3 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -35,6 +35,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"] @@ -92,42 +135,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/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a110d67efd33..c37bd8440c80 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -37,6 +37,7 @@ 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" }) @@ -138,6 +139,7 @@ export namespace ToolRegistry { BrowserTool, SwarmTool, BriefTool, + SnipTool, EnterWorktreeTool, ExitWorktreeTool, SkillTool, 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/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") + }, + }) + }) +}) From ca6e7b49836654d62960446477a275d2680e065d Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 15:26:07 -0700 Subject: [PATCH 20/40] feat: add lightweight trigger service --- packages/opencode/src/server/instance.ts | 2 + .../opencode/src/server/routes/trigger.ts | 53 ++++++ packages/opencode/src/trigger/index.ts | 151 ++++++++++++++++++ packages/opencode/test/server/trigger.test.ts | 45 ++++++ .../opencode/test/trigger/trigger.test.ts | 42 +++++ 5 files changed, 293 insertions(+) create mode 100644 packages/opencode/src/server/routes/trigger.ts create mode 100644 packages/opencode/src/trigger/index.ts create mode 100644 packages/opencode/test/server/trigger.test.ts create mode 100644 packages/opencode/test/trigger/trigger.test.ts 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..bfdbf3343d06 --- /dev/null +++ b/packages/opencode/src/server/routes/trigger.ts @@ -0,0 +1,53 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { Trigger } from "@/trigger" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +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"))) + }, + ), +) diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts new file mode 100644 index 000000000000..fc54a12f78f6 --- /dev/null +++ b/packages/opencode/src/trigger/index.ts @@ -0,0 +1,151 @@ +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 { 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" }) + + export const Info = z + .object({ + id: z.string(), + schedule: z.object({ + type: z.literal("interval"), + interval: z.number().int().positive(), + }), + runs: z.number().int().nonnegative(), + 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 + + export const CreateInput = z.object({ + interval: z.number().int().min(10).max(86_400_000), + }) + export type CreateInput = z.infer + + export const Event = { + Fired: BusEvent.define( + "trigger.fired", + z.object({ + triggerID: z.string(), + runs: z.number().int().nonnegative(), + at: z.number().int().nonnegative(), + }), + ), + } + + type State = { + create: (input: CreateInput) => Effect.Effect + list: () => Effect.Effect + } + + export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Trigger") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = yield* InstanceState.make( + Effect.fn("Trigger.state")(function* () { + const data = new Map() + + const tick = Effect.fnUntraced(function* () { + const now = Date.now() + yield* Effect.forEach( + Array.from(data.values()).filter((item) => item.time.next <= now), + (item) => + Effect.gen(function* () { + const at = Date.now() + const next = { + ...item, + runs: item.runs + 1, + time: { + ...item.time, + last: at, + next: at + item.schedule.interval, + }, + } + data.set(item.id, next) + yield* bus.publish(Event.Fired, { + triggerID: item.id, + runs: next.runs, + at, + }) + }), + { 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 item = { + id: `trg_${randomUUID().replaceAll("-", "")}`, + schedule: { + type: "interval" as const, + interval: input.interval, + }, + runs: 0, + time: { + created: now, + next: now + input.interval, + }, + } satisfies Info + data.set(item.id, item) + return item + }) + + const list = Effect.fn("Trigger.list")(() => + Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), + ) + + return { create, list } + }), + ) + + return Service.of({ + create: Effect.fn("Trigger.create")(function* (input: CreateInput) { + return yield* InstanceState.useEffect(state, (svc) => svc.create(input)) + }), + list: Effect.fn("Trigger.list")(function* () { + return yield* InstanceState.useEffect(state, (svc) => svc.list()) + }), + }) + }), + ) + + 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()) + } +} diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts new file mode 100644 index 000000000000..4a03a4e9a7d1 --- /dev/null +++ b/packages/opencode/test/server/trigger.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" + +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) + }, + }) + }) +}) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts new file mode 100644 index 000000000000..35d54b6e10b4 --- /dev/null +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Trigger } from "../../src/trigger" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + 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 }, + 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([]) + }, + }) + }) +}) From f80c32a21e5a60166b5df7c360d8bde7e7eedd30 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 15:34:48 -0700 Subject: [PATCH 21/40] feat: add remote attach preflight --- packages/opencode/src/cli/cmd/remote.ts | 31 ++++ packages/opencode/src/cli/cmd/run.ts | 12 +- packages/opencode/src/cli/cmd/tui/attach.ts | 9 + .../test/cli/remote-preflight.test.ts | 164 ++++++++++++++++++ 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/remote.ts create mode 100644 packages/opencode/test/cli/remote-preflight.test.ts diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts new file mode 100644 index 000000000000..8b420dbcd319 --- /dev/null +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -0,0 +1,31 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" + +type Input = { + url: string + directory?: string + headers?: RequestInit["headers"] + fetch?: typeof globalThis.fetch +} + +export async function preflightRemote(input: Input): Promise { + const sdk = createOpencodeClient({ + baseUrl: input.url, + directory: input.directory, + 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}`) + } +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e8679..b16a279d4ca7 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 } from "./remote" type ToolProps = { input: Tool.InferParameters @@ -660,7 +661,14 @@ export const RunCommand = cmd({ const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` return { Authorization: auth } })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = await preflightRemote({ + url: args.attach, + directory, + headers, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) return await execute(sdk) } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d1b..80e60220e902 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 } from "../remote" export const AttachCommand = cmd({ command: "attach ", @@ -66,6 +67,14 @@ export const AttachCommand = cmd({ const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() + await preflightRemote({ + url: args.url, + directory, + headers, + }).catch((error) => { + UI.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) const config = await Instance.provide({ directory: directory && existsSync(directory) ? directory : process.cwd(), fn: () => TuiConfig.get(), 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..f2a8ff974c18 --- /dev/null +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -0,0 +1,164 @@ +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 * 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", + continue: false, + session: undefined, + fork: false, + password: undefined, + }) + + expect(get).toHaveBeenCalledTimes(1) + expect(tui).toHaveBeenCalledTimes(1) + }) + + 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", + }, + })), + }, + }), + ) + + await expect( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + continue: false, + session: undefined, + fork: false, + password: undefined, + }), + ).rejects.toBe(exit) + + expect(err).toHaveBeenCalled() + expect(tui).not.toHaveBeenCalled() + }) + + 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 { + await expect( + 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, + port: undefined, + variant: undefined, + thinking: false, + }), + ).rejects.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() + }) +}) From 45c789ec246795ebabf666ff103f42d176e99dbf Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 15:47:06 -0700 Subject: [PATCH 22/40] feat: execute trigger command actions --- packages/opencode/src/trigger/index.ts | 35 ++++++++++ .../opencode/test/trigger/trigger.test.ts | 67 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts index fc54a12f78f6..655e82b6aa81 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -3,6 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { SessionPrompt } from "@/session/prompt" +import { SessionStatus } from "@/session/status" +import { SessionID } from "@/session/schema" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" import z from "zod" import { Log } from "../util/log" @@ -10,6 +13,15 @@ import { Log } from "../util/log" export namespace Trigger { const log = Log.create({ service: "trigger" }) + const Action = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("command"), + sessionID: SessionID.zod, + command: z.string(), + arguments: z.string().optional(), + }), + ]) + export const Info = z .object({ id: z.string(), @@ -17,6 +29,7 @@ export namespace Trigger { type: z.literal("interval"), interval: z.number().int().positive(), }), + action: Action.optional(), runs: z.number().int().nonnegative(), time: z.object({ created: z.number().int().nonnegative(), @@ -31,6 +44,7 @@ export namespace Trigger { export const CreateInput = z.object({ interval: z.number().int().min(10).max(86_400_000), + action: Action.optional(), }) export type CreateInput = z.infer @@ -87,6 +101,26 @@ export namespace Trigger { runs: next.runs, at, }) + const action = item.action + if (!action) return + const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID)) + if (st.type !== "idle") return + yield* Effect.promise(() => + SessionPrompt.command({ + sessionID: action.sessionID, + command: action.command, + arguments: action.arguments ?? "", + }), + ).pipe( + Effect.catchCause((cause) => + Effect.sync(() => + log.error("trigger action failed", { + triggerID: item.id, + cause: Cause.pretty(cause), + }), + ), + ), + ) }), { discard: true }, ) @@ -109,6 +143,7 @@ export namespace Trigger { type: "interval" as const, interval: input.interval, }, + action: input.action, runs: 0, time: { created: now, diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 35d54b6e10b4..844ba67857ab 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -1,9 +1,13 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" 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 { tmpdir } from "../fixture/fixture" afterEach(async () => { + mock.restore() await Instance.disposeAll() }) @@ -39,4 +43,65 @@ describe("trigger service", () => { }, }) }) + + 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) + + expect(command).toHaveBeenCalledWith({ + sessionID: session.id, + command: "init", + arguments: "--help", + }) + }, + }) + }) + + 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) + + expect(command).not.toHaveBeenCalled() + }, + }) + }) }) From e3485ac05b8155255340f9324d5539deda981f8e Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:01:22 -0700 Subject: [PATCH 23/40] feat: add trigger lifecycle controls --- .../opencode/src/server/routes/trigger.ts | 96 +++++++++++++++++++ packages/opencode/src/trigger/index.ts | 69 ++++++++++++- packages/opencode/test/server/trigger.test.ts | 67 +++++++++++++ .../opencode/test/trigger/trigger.test.ts | 50 ++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index bfdbf3343d06..c75e3ac7314f 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -1,9 +1,12 @@ 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() }) + export const TriggerRoutes = lazy(() => new Hono() .get( @@ -49,5 +52,98 @@ export const TriggerRoutes = lazy(() => 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/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/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 655e82b6aa81..17acf3977b34 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -6,6 +6,7 @@ import { makeRuntime } from "@/effect/run-service" import { SessionPrompt } from "@/session/prompt" import { SessionStatus } from "@/session/status" import { SessionID } from "@/session/schema" +import { NotFoundError } from "@/storage/db" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" import z from "zod" import { Log } from "../util/log" @@ -30,6 +31,7 @@ export namespace Trigger { interval: z.number().int().positive(), }), action: Action.optional(), + enabled: z.boolean(), runs: z.number().int().nonnegative(), time: z.object({ created: z.number().int().nonnegative(), @@ -59,14 +61,24 @@ export namespace Trigger { ), } + type Err = InstanceType + type State = { create: (input: CreateInput) => Effect.Effect + get: (id: string) => Effect.Effect list: () => 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 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") {} @@ -79,10 +91,18 @@ export namespace Trigger { Effect.fn("Trigger.state")(function* () { const data = new Map() + 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 tick = Effect.fnUntraced(function* () { const now = Date.now() yield* Effect.forEach( - Array.from(data.values()).filter((item) => item.time.next <= now), + Array.from(data.values()).filter((item) => item.enabled && item.time.next <= now), (item) => Effect.gen(function* () { const at = Date.now() @@ -144,6 +164,7 @@ export namespace Trigger { interval: input.interval, }, action: input.action, + enabled: true, runs: 0, time: { created: now, @@ -154,11 +175,27 @@ export namespace Trigger { return item }) + const update = Effect.fnUntraced(function* (id: string, enabled: boolean) { + const item = yield* get(id) + const next = { ...item, enabled } + data.set(id, next) + return next + }) + const list = Effect.fn("Trigger.list")(() => Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), ) - return { create, list } + 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) + }) + + return { create, get, list, enable, disable, delete: del } }), ) @@ -166,9 +203,21 @@ export namespace Trigger { 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()) }), + 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)) + }), }) }), ) @@ -183,4 +232,20 @@ export namespace Trigger { 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 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/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 4a03a4e9a7d1..38dcf6aa9112 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -42,4 +42,71 @@ describe("trigger routes", () => { }, }) }) + + 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) + }, + }) + }) }) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 844ba67857ab..972cce530255 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -25,6 +25,7 @@ describe("trigger service", () => { expect(list[0]).toMatchObject({ id: item.id, schedule: { interval: 20 }, + enabled: true, runs: 0, }) @@ -44,6 +45,55 @@ describe("trigger service", () => { }) }) + 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("fires command action for an idle session", async () => { await using tmp = await tmpdir({ git: true }) From 7de0762035bf67f052fe9e6190c34287d6777e5e Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:02:21 -0700 Subject: [PATCH 24/40] feat: validate remote session targets --- packages/opencode/src/cli/cmd/remote.ts | 39 +++ packages/opencode/src/cli/cmd/run.ts | 23 +- packages/opencode/src/cli/cmd/tui/attach.ts | 14 +- .../test/cli/remote-preflight.test.ts | 229 +++++++++++++++++- 4 files changed, 293 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 8b420dbcd319..b99e9b16a79a 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -7,6 +7,26 @@ type Input = { fetch?: typeof globalThis.fetch } +type TargetInput = { + sdk: OpencodeClient + directory?: string + continue?: boolean + sessionID?: string + fork?: boolean +} + +type Target = { + baseID?: 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, @@ -29,3 +49,22 @@ export async function preflightRemote(input: Input): Promise { 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 baseID = result.data?.find((item) => !item.parentID)?.id + if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`) + return { baseID } +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b16a279d4ca7..5251e8fc1b34 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,7 +27,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" -import { preflightRemote } from "./remote" +import { preflightRemote, resolveRemoteTarget } from "./remote" type ToolProps = { input: Tool.InferParameters @@ -379,8 +379,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 }) @@ -409,7 +410,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)) @@ -620,7 +621,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) @@ -669,7 +670,17 @@ export const RunCommand = cmd({ UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) - return await execute(sdk) + 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/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 80e60220e902..2e212acfdd6d 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,7 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" import { existsSync } from "fs" -import { preflightRemote } from "../remote" +import { preflightRemote, resolveRemoteTarget } from "../remote" export const AttachCommand = cmd({ command: "attach ", @@ -67,7 +67,7 @@ export const AttachCommand = cmd({ const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() - await preflightRemote({ + const sdk = await preflightRemote({ url: args.url, directory, headers, @@ -75,6 +75,16 @@ export const AttachCommand = cmd({ UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) + 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) + }) const config = await Instance.provide({ directory: directory && existsSync(directory) ? directory : process.cwd(), fn: () => TuiConfig.get(), diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index f2a8ff974c18..b489d27a1984 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -66,7 +66,7 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) - test("attach fails clearly when the remote directory does not match", async () => { + test("attach fails clearly when the remote directory does not match", () => { stopExit() mockAttach() const err = spyOn(UI, "error").mockImplementation(() => {}) @@ -87,7 +87,7 @@ describe("remote preflight", () => { }), ) - await expect( + return expect( AttachCommand.handler({ _: [], $0: "opencode", @@ -104,7 +104,90 @@ describe("remote preflight", () => { expect(tui).not.toHaveBeenCalled() }) - test("run --attach fails before creating a session when the remote is unreachable", async () => { + test("attach fails before starting tui when the remote session is missing", () => { + 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 }, + }), + ) + + return expect( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + continue: false, + session: "missing", + fork: false, + password: undefined, + }), + ).rejects.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", + continue: false, + session: "sess_123", + fork: false, + password: undefined, + }) + + expect(get).toHaveBeenCalledWith({ sessionID: "sess_123" }, { throwOnError: true }) + expect(tui).toHaveBeenCalledTimes(1) + }) + + test("run --attach fails before creating a session when the remote is unreachable", () => { stopExit() const err = spyOn(UI, "error").mockImplementation(() => {}) const create = mock(async () => { @@ -130,7 +213,7 @@ describe("remote preflight", () => { }) try { - await expect( + return expect( RunCommand.handler({ _: [], $0: "opencode", @@ -161,4 +244,142 @@ describe("remote preflight", () => { expect(err).toHaveBeenCalled() expect(create).not.toHaveBeenCalled() }) + + test("run --attach fails before creating a session when the remote continue target is missing", () => { + 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 { + return expect( + 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", + port: undefined, + variant: undefined, + thinking: false, + }), + ).rejects.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", () => { + 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 { + return expect( + 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", + port: undefined, + variant: undefined, + thinking: false, + }), + ).rejects.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() + }) }) From 6a622ac354c4f38f1610f9386feecaff0e7b9869 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:12:56 -0700 Subject: [PATCH 25/40] feat: add manual trigger fire --- .../opencode/src/server/routes/trigger.ts | 23 +++++ packages/opencode/src/trigger/index.ts | 98 +++++++++++-------- packages/opencode/test/server/trigger.test.ts | 30 ++++++ .../opencode/test/trigger/trigger.test.ts | 52 ++++++++++ 4 files changed, 161 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index c75e3ac7314f..6c200eecd89a 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -76,6 +76,29 @@ export const TriggerRoutes = lazy(() => 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), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.fire(c.req.valid("param").id)) + }, + ) .post( "/:id/enable", describeRoute({ diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 17acf3977b34..519aa2c1ac52 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -67,6 +67,7 @@ export namespace Trigger { create: (input: CreateInput) => Effect.Effect get: (id: string) => Effect.Effect list: () => Effect.Effect + fire: (id: string) => Effect.Effect enable: (id: string) => Effect.Effect disable: (id: string) => Effect.Effect delete: (id: string) => Effect.Effect @@ -76,6 +77,7 @@ export namespace Trigger { readonly create: (input: CreateInput) => Effect.Effect readonly get: (id: string) => Effect.Effect readonly list: () => Effect.Effect + readonly fire: (id: string) => Effect.Effect readonly enable: (id: string) => Effect.Effect readonly disable: (id: string) => Effect.Effect readonly delete: (id: string) => Effect.Effect @@ -99,49 +101,50 @@ export namespace Trigger { }), ) + const run = Effect.fnUntraced(function* (item: Info) { + const at = Date.now() + const next = { + ...item, + runs: item.runs + 1, + time: { + ...item.time, + last: at, + next: at + item.schedule.interval, + }, + } + data.set(item.id, next) + yield* bus.publish(Event.Fired, { + triggerID: item.id, + runs: next.runs, + at, + }) + const action = item.action + if (!action) return next + const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID)) + if (st.type !== "idle") return next + yield* Effect.promise(() => + SessionPrompt.command({ + sessionID: action.sessionID, + command: action.command, + arguments: action.arguments ?? "", + }), + ).pipe( + Effect.catchCause((cause) => + Effect.sync(() => + log.error("trigger action failed", { + triggerID: item.id, + cause: Cause.pretty(cause), + }), + ), + ), + ) + return next + }) + const tick = Effect.fnUntraced(function* () { - const now = Date.now() yield* Effect.forEach( - Array.from(data.values()).filter((item) => item.enabled && item.time.next <= now), - (item) => - Effect.gen(function* () { - const at = Date.now() - const next = { - ...item, - runs: item.runs + 1, - time: { - ...item.time, - last: at, - next: at + item.schedule.interval, - }, - } - data.set(item.id, next) - yield* bus.publish(Event.Fired, { - triggerID: item.id, - runs: next.runs, - at, - }) - const action = item.action - if (!action) return - const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID)) - if (st.type !== "idle") return - yield* Effect.promise(() => - SessionPrompt.command({ - sessionID: action.sessionID, - command: action.command, - arguments: action.arguments ?? "", - }), - ).pipe( - Effect.catchCause((cause) => - Effect.sync(() => - log.error("trigger action failed", { - triggerID: item.id, - cause: Cause.pretty(cause), - }), - ), - ), - ) - }), + Array.from(data.values()).filter((item) => item.enabled && item.time.next <= Date.now()), + (item) => run(item), { discard: true }, ) }) @@ -186,6 +189,10 @@ export namespace Trigger { Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), ) + const fire = Effect.fn("Trigger.fire")(function* (id: string) { + return yield* run(yield* get(id)) + }) + const enable = Effect.fn("Trigger.enable")((id: string) => update(id, true)) const disable = Effect.fn("Trigger.disable")((id: string) => update(id, false)) @@ -195,7 +202,7 @@ export namespace Trigger { data.delete(id) }) - return { create, get, list, enable, disable, delete: del } + return { create, get, list, fire, enable, disable, delete: del } }), ) @@ -209,6 +216,9 @@ export namespace Trigger { list: Effect.fn("Trigger.list")(function* () { return yield* InstanceState.useEffect(state, (svc) => svc.list()) }), + fire: Effect.fn("Trigger.fire")(function* (id: string) { + return yield* InstanceState.useEffect(state, (svc) => svc.fire(id)) + }), enable: Effect.fn("Trigger.enable")(function* (id: string) { return yield* InstanceState.useEffect(state, (svc) => svc.enable(id)) }), @@ -241,6 +251,10 @@ export namespace Trigger { return runPromise((svc) => svc.enable(id)) } + export async function fire(id: string) { + return runPromise((svc) => svc.fire(id)) + } + export async function disable(id: string) { return runPromise((svc) => svc.disable(id)) } diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 38dcf6aa9112..5aaef4f152e2 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -109,4 +109,34 @@ describe("trigger routes", () => { }, }) }) + + 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, + time: { + created: item.time.created, + last: expect.any(Number), + }, + }) + }, + }) + }) }) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 972cce530255..4e488ecaab65 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -1,4 +1,5 @@ import { afterEach, 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" @@ -154,4 +155,55 @@ describe("trigger service", () => { }, }) }) + + 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, + }, + ]) + }, + }) + }) }) From 5e2ab869f2c467023ed238b0f1ac583bfe2e1225 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:19:40 -0700 Subject: [PATCH 26/40] feat: confirm remote continue targets --- packages/opencode/src/cli/cmd/remote.ts | 6 +- packages/opencode/src/cli/cmd/tui/attach.ts | 9 +- .../test/cli/remote-preflight.test.ts | 276 +++++++++++------- 3 files changed, 190 insertions(+), 101 deletions(-) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index b99e9b16a79a..2a3f921c62f3 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -17,6 +17,7 @@ type TargetInput = { type Target = { baseID?: string + title?: string } function suffix(dir?: string) { @@ -64,7 +65,8 @@ export async function resolveRemoteTarget(input: TargetInput): Promise { 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 baseID = result.data?.find((item) => !item.parentID)?.id + const item = result.data?.find((item) => !item.parentID) + const baseID = item?.id if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`) - return { baseID } + return { baseID, title: item?.title } } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2e212acfdd6d..70e2214ba441 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -75,7 +75,7 @@ export const AttachCommand = cmd({ UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) - await resolveRemoteTarget({ + const target = await resolveRemoteTarget({ sdk, directory, continue: args.continue, @@ -85,6 +85,13 @@ export const AttachCommand = cmd({ 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(), diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index b489d27a1984..b1493963cbff 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -66,7 +66,7 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) - test("attach fails clearly when the remote directory does not match", () => { + test("attach fails clearly when the remote directory does not match", async () => { stopExit() mockAttach() const err = spyOn(UI, "error").mockImplementation(() => {}) @@ -87,24 +87,30 @@ describe("remote preflight", () => { }), ) - return expect( - AttachCommand.handler({ - _: [], - $0: "opencode", - url: "http://remote.test", - dir: "/srv/app", - continue: false, - session: undefined, - fork: false, - password: undefined, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + 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", () => { + test("attach fails before starting tui when the remote session is missing", async () => { stopExit() mockAttach() const err = spyOn(UI, "error").mockImplementation(() => {}) @@ -129,19 +135,25 @@ describe("remote preflight", () => { }), ) - return expect( - AttachCommand.handler({ - _: [], - $0: "opencode", - url: "http://remote.test", - dir: "/srv/app", - continue: false, - session: "missing", - fork: false, - password: undefined, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + 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() @@ -187,7 +199,57 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) - test("run --attach fails before creating a session when the remote is unreachable", () => { + 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", + 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) + }) + + 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 () => { @@ -213,29 +275,35 @@ describe("remote preflight", () => { }) try { - return expect( - 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, - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + 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, + 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 @@ -245,7 +313,7 @@ describe("remote preflight", () => { expect(create).not.toHaveBeenCalled() }) - test("run --attach fails before creating a session when the remote continue target is missing", () => { + 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 () => { @@ -281,29 +349,35 @@ describe("remote preflight", () => { }) try { - return expect( - 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", - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + 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", + 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 @@ -314,7 +388,7 @@ describe("remote preflight", () => { expect(create).not.toHaveBeenCalled() }) - test("run --attach fails before forking when the remote fork base is missing", () => { + 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 () => { @@ -350,29 +424,35 @@ describe("remote preflight", () => { }) try { - return expect( - 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", - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + 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", + 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 From ea03bdb7b3c5096807d898d47651e19574e0f8a3 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:28:58 -0700 Subject: [PATCH 27/40] feat: add trigger fire webhook endpoint --- packages/opencode/src/flag/flag.ts | 20 ++++++- .../opencode/src/server/routes/trigger.ts | 23 +++++++ packages/opencode/test/server/trigger.test.ts | 60 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) 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/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index 6c200eecd89a..a78452039c4a 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -99,6 +99,29 @@ export const TriggerRoutes = lazy(() => return c.json(await Trigger.fire(c.req.valid("param").id)) }, ) + .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), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", Params), + async (c) => { + return c.json(await Trigger.fire(c.req.valid("param").id)) + }, + ) .post( "/:id/enable", describeRoute({ diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 5aaef4f152e2..043a4ef1d427 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -1,8 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" +import { Trigger } from "../../src/trigger" import { tmpdir } from "../fixture/fixture" +function auth(password: string, username = "opencode") { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + afterEach(async () => { await Instance.disposeAll() }) @@ -139,4 +144,59 @@ describe("trigger routes", () => { }, }) }) + + 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, + time: { + created: item.time.created, + last: expect.any(Number), + }, + }) + }) + + 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 + } + }) }) From b7e620bf151cfbf25aaa3e4cff746c493fcb228a Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:33:47 -0700 Subject: [PATCH 28/40] feat: pick remote continue sessions --- packages/opencode/src/cli/cmd/remote.ts | 8 +++-- packages/opencode/src/cli/cmd/tui/attach.ts | 28 +++++++++++++-- .../test/cli/remote-preflight.test.ts | 34 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 2a3f921c62f3..c24496879640 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -13,11 +13,13 @@ type TargetInput = { continue?: boolean sessionID?: string fork?: boolean + pick?: (items: { id: string; title?: string; parentID?: string }[]) => Promise } type Target = { baseID?: string title?: string + picked?: boolean } function suffix(dir?: string) { @@ -65,8 +67,10 @@ export async function resolveRemoteTarget(input: TargetInput): Promise { 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 item = result.data?.find((item) => !item.parentID) + const items = (result.data ?? []).filter((item) => !item.parentID) + 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 } + return { baseID, title: item?.title, picked: items.length > 1 && !!input.pick } } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 70e2214ba441..20644aa5020d 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -6,6 +6,29 @@ import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" import { existsSync } from "fs" import { preflightRemote, resolveRemoteTarget } from "../remote" +import { createInterface } from "readline/promises" + +async function pick(items: { id: string; title?: string }[]) { + if (items.length < 2) return items[0]?.id + UI.println(UI.Style.TEXT_INFO_BOLD + "Select remote session" + UI.Style.TEXT_NORMAL) + items.forEach((item, i) => { + UI.println(` ${i + 1}. ${item.title ?? item.id} ${UI.Style.TEXT_DIM}(${item.id})${UI.Style.TEXT_NORMAL}`) + }) + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + try { + while (true) { + const txt = (await rl.question("Enter session number: ")).trim() + const n = Number(txt) + if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1]?.id + UI.error(`Choose a number between 1 and ${items.length}`) + } + } finally { + rl.close() + } +} export const AttachCommand = cmd({ command: "attach ", @@ -81,6 +104,7 @@ export const AttachCommand = cmd({ continue: args.continue, sessionID: args.session, fork: args.fork, + pick: args.continue && !args.session ? pick : undefined, }).catch((error) => { UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) @@ -100,8 +124,8 @@ export const AttachCommand = cmd({ url: args.url, config, args: { - continue: args.continue, - sessionID: args.session, + continue: target.picked ? false : args.continue, + sessionID: target.picked ? target.baseID : args.session, fork: args.fork, }, directory, diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index b1493963cbff..aa8400be52d0 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -3,6 +3,7 @@ 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" @@ -249,6 +250,39 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) + 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(() => {}) From 7faded3d7ccf7bb4f5920095934995482b62411a Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 16:57:30 -0700 Subject: [PATCH 29/40] feat: persist trigger state --- packages/opencode/src/session/session.sql.ts | 20 +++++ packages/opencode/src/storage/schema.ts | 2 +- packages/opencode/src/trigger/index.ts | 90 ++++++++++++++++++- packages/opencode/test/server/trigger.test.ts | 7 +- .../opencode/test/trigger/trigger.test.ts | 36 +++++++- 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 189a596873a3..0dbabbb19aec 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" @@ -101,3 +102,22 @@ 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(), + enabled: integer({ mode: "boolean" }).notNull(), + runs: integer().notNull(), + ...Timestamps, + time_last: integer(), + time_next: integer().notNull(), + }, + (table) => [index("trigger_project_idx").on(table.project_id)], +) 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/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 519aa2c1ac52..12fc0a000c12 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -3,10 +3,12 @@ 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 { NotFoundError } from "@/storage/db" +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" @@ -85,13 +87,91 @@ export namespace Trigger { 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, + enabled: item.enabled, + runs: item.runs, + time_created: item.time.created, + time_updated, + time_last: 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 } : {}), + enabled: row.enabled, + runs: row.runs, + 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, + 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() + 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* () { - const data = new Map() + 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(() => { @@ -113,6 +193,7 @@ export namespace Trigger { }, } data.set(item.id, next) + yield* save(next) yield* bus.publish(Event.Fired, { triggerID: item.id, runs: next.runs, @@ -175,6 +256,7 @@ export namespace Trigger { }, } satisfies Info data.set(item.id, item) + yield* save(item) return item }) @@ -182,6 +264,7 @@ export namespace Trigger { const item = yield* get(id) const next = { ...item, enabled } data.set(id, next) + yield* save(next) return next }) @@ -200,6 +283,7 @@ export namespace Trigger { 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 } diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 043a4ef1d427..31d197e16845 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -1,13 +1,18 @@ -import { afterEach, describe, expect, test } from "bun:test" +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() }) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 4e488ecaab65..cf3926504dcb 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -1,12 +1,17 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +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() @@ -95,6 +100,35 @@ describe("trigger service", () => { }) }) + 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("fires command action for an idle session", async () => { await using tmp = await tmpdir({ git: true }) From e5277e5d4a3285b95addf5e39ed18f91be43c260 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 17:07:11 -0700 Subject: [PATCH 30/40] feat: add TUI remote session picker --- packages/opencode/src/cli/cmd/remote.ts | 13 +++ packages/opencode/src/cli/cmd/tui/app.tsx | 8 ++ packages/opencode/src/cli/cmd/tui/attach.ts | 28 +----- .../component/dialog-remote-session-list.tsx | 90 +++++++++++++++++++ .../opencode/src/cli/cmd/tui/context/args.tsx | 4 + .../test/cli/remote-preflight.test.ts | 74 +++++++++++++++ .../test/cli/tui/attach-startup.test.ts | 52 +++++++++++ 7 files changed, 244 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx create mode 100644 packages/opencode/test/cli/tui/attach-startup.test.ts diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index c24496879640..7a81fe30e9a7 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -13,6 +13,7 @@ type TargetInput = { continue?: boolean sessionID?: string fork?: boolean + defer?: boolean pick?: (items: { id: string; title?: string; parentID?: string }[]) => Promise } @@ -20,6 +21,10 @@ type Target = { baseID?: string title?: string picked?: boolean + remoteSessions?: { + id: string + title?: string + }[] } function suffix(dir?: string) { @@ -68,6 +73,14 @@ export async function resolveRemoteTarget(input: TargetInput): Promise { 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 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ec048f86b2f1..6fa60339d848 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -31,6 +31,7 @@ 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, 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 +124,8 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +export { selectRemoteSession } + function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { return { externalOutputMode: "passthrough", @@ -378,6 +381,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({ diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 20644aa5020d..2483fc0578c3 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -6,29 +6,6 @@ import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" import { existsSync } from "fs" import { preflightRemote, resolveRemoteTarget } from "../remote" -import { createInterface } from "readline/promises" - -async function pick(items: { id: string; title?: string }[]) { - if (items.length < 2) return items[0]?.id - UI.println(UI.Style.TEXT_INFO_BOLD + "Select remote session" + UI.Style.TEXT_NORMAL) - items.forEach((item, i) => { - UI.println(` ${i + 1}. ${item.title ?? item.id} ${UI.Style.TEXT_DIM}(${item.id})${UI.Style.TEXT_NORMAL}`) - }) - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }) - try { - while (true) { - const txt = (await rl.question("Enter session number: ")).trim() - const n = Number(txt) - if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1]?.id - UI.error(`Choose a number between 1 and ${items.length}`) - } - } finally { - rl.close() - } -} export const AttachCommand = cmd({ command: "attach ", @@ -104,7 +81,7 @@ export const AttachCommand = cmd({ continue: args.continue, sessionID: args.session, fork: args.fork, - pick: args.continue && !args.session ? pick : undefined, + defer: args.continue && !args.session, }).catch((error) => { UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) @@ -124,9 +101,10 @@ export const AttachCommand = cmd({ url: args.url, config, args: { - continue: target.picked ? false : args.continue, + continue: target.remoteSessions || target.picked ? false : args.continue, sessionID: target.picked ? target.baseID : args.session, 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..5e08da5bcae2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx @@ -0,0 +1,90 @@ +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 Input = { + 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 + } +} + +export async function selectRemoteSession(input: Input) { + 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 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={true} + onSelect={(option) => { + void selectRemoteSession({ + id: option.value, + fork: props.fork, + route, + dialog, + sdk, + toast, + }) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 8a229ffaba69..4ab18c7a9af3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -7,6 +7,10 @@ export interface Args { continue?: boolean sessionID?: string fork?: boolean + remoteSessions?: { + id: string + title?: string + }[] } export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index aa8400be52d0..52ec52c6168b 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -248,6 +248,80 @@ describe("remote preflight", () => { 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", + 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 () => { 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..4a1604f0186f --- /dev/null +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" + +afterEach(() => { + mock.restore() +}) + +describe("attach startup", () => { + test("selected remote session navigates inside the tui flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/app") + const fn = mod["selectRemoteSession"] + 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_456", + fork: false, + route, + dialog, + sdk, + toast, + }) + + expect(route.navigate).toHaveBeenCalledWith({ + type: "session", + sessionID: "sess_456", + }) + expect(dialog.clear).toHaveBeenCalledTimes(1) + expect(sdk.client.session.fork).not.toHaveBeenCalled() + expect(toast.show).not.toHaveBeenCalled() + }) +}) From 9a7768097cdeb077b429f67d8e334c77a99df530 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 17:27:04 -0700 Subject: [PATCH 31/40] feat: add trigger webhook secrets --- .../opencode/src/server/routes/trigger.ts | 24 +++++++++- packages/opencode/src/session/session.sql.ts | 1 + packages/opencode/src/trigger/index.ts | 10 ++++ packages/opencode/test/server/trigger.test.ts | 48 +++++++++++++++++++ .../opencode/test/trigger/trigger.test.ts | 29 +++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index a78452039c4a..d445102d37d8 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -6,6 +6,7 @@ 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() @@ -91,6 +92,14 @@ export const TriggerRoutes = lazy(() => }, }, }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(AuthError), + }, + }, + }, ...errors(404), }, }), @@ -114,12 +123,25 @@ export const TriggerRoutes = lazy(() => }, }, }, + 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)) + 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)) }, ) .post( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 0dbabbb19aec..715f73f45db9 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -113,6 +113,7 @@ export const TriggerTable = sqliteTable( .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, diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 12fc0a000c12..5dc017af2b6c 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -33,6 +33,7 @@ export namespace Trigger { interval: z.number().int().positive(), }), action: Action.optional(), + webhook_secret: z.string().min(1).optional(), enabled: z.boolean(), runs: z.number().int().nonnegative(), time: z.object({ @@ -49,6 +50,7 @@ export namespace Trigger { export const CreateInput = z.object({ interval: z.number().int().min(10).max(86_400_000), action: Action.optional(), + webhook_secret: z.string().min(1).optional(), }) export type CreateInput = z.infer @@ -92,6 +94,7 @@ export namespace Trigger { project_id, schedule: item.schedule, action: item.action ?? null, + webhook_secret: item.webhook_secret ?? null, enabled: item.enabled, runs: item.runs, time_created: item.time.created, @@ -104,6 +107,7 @@ export namespace Trigger { 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, time: { @@ -122,6 +126,7 @@ export namespace Trigger { 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, @@ -132,6 +137,10 @@ export namespace Trigger { `, ) .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() + } Database.Client().$client.query(`CREATE INDEX IF NOT EXISTS trigger_project_idx ON trigger (project_id)`).run() }) @@ -248,6 +257,7 @@ export namespace Trigger { interval: input.interval, }, action: input.action, + webhook_secret: input.webhook_secret, enabled: true, runs: 0, time: { diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 31d197e16845..7ad853067780 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -173,6 +173,54 @@ describe("trigger routes", () => { }) }) + 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({ diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index cf3926504dcb..6471b45d729b 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -129,6 +129,35 @@ describe("trigger service", () => { }) }) + 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("fires command action for an idle session", async () => { await using tmp = await tmpdir({ git: true }) From 00645ed98df82d453600ee8d58e9a60d3f768bd5 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 17:32:30 -0700 Subject: [PATCH 32/40] feat: browse remote child sessions --- .../component/dialog-remote-session-list.tsx | 65 +++++++++- .../test/cli/tui/attach-startup.test.ts | 114 +++++++++++++++++- 2 files changed, 175 insertions(+), 4 deletions(-) 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 index 5e08da5bcae2..9cba3e011e09 100644 --- 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 @@ -10,7 +10,7 @@ type Session = { title?: string } -type Input = { +type OpenInput = { id: string fork?: boolean route: RouteContext @@ -27,7 +27,20 @@ type Input = { } } -export async function selectRemoteSession(input: Input) { +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", @@ -56,6 +69,53 @@ export async function selectRemoteSession(input: Input) { 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() + + return ( + ({ + title: item.title ?? item.id, + value: item.id, + footer: item.id, + }))} + skipFilter={true} + onSelect={(option) => { + 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() @@ -78,6 +138,7 @@ export function DialogRemoteSessionList(props: { sessions: Session[]; fork?: boo onSelect={(option) => { void selectRemoteSession({ id: option.value, + title: props.sessions.find((item) => item.id === option.value)?.title, fork: props.fork, route, dialog, diff --git a/packages/opencode/test/cli/tui/attach-startup.test.ts b/packages/opencode/test/cli/tui/attach-startup.test.ts index 4a1604f0186f..8ba070716c3e 100644 --- a/packages/opencode/test/cli/tui/attach-startup.test.ts +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -5,8 +5,8 @@ afterEach(() => { }) describe("attach startup", () => { - test("selected remote session navigates inside the tui flow", async () => { - const mod: Record = await import("../../../src/cli/cmd/tui/app") + 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") @@ -15,10 +15,15 @@ describe("attach startup", () => { } const dialog = { clear: mock(() => {}), + replace: mock(() => {}), } + const children = mock(async () => ({ + data: [], + })) const sdk = { client: { session: { + children, fork: mock(async () => ({ data: { id: "sess_forked", @@ -45,6 +50,111 @@ describe("attach startup", () => { 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() From 68e79822eeb39910d654edaf17ef3e3111c8f006 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 17:54:10 -0700 Subject: [PATCH 33/40] feat: record trigger execution state --- .../opencode/src/server/routes/trigger.ts | 4 +- packages/opencode/src/session/session.sql.ts | 4 + packages/opencode/src/trigger/index.ts | 100 ++++++++++++++---- packages/opencode/test/server/trigger.test.ts | 10 ++ .../opencode/test/trigger/trigger.test.ts | 49 +++++++++ 5 files changed, 144 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/server/routes/trigger.ts b/packages/opencode/src/server/routes/trigger.ts index d445102d37d8..85524abec263 100644 --- a/packages/opencode/src/server/routes/trigger.ts +++ b/packages/opencode/src/server/routes/trigger.ts @@ -105,7 +105,7 @@ export const TriggerRoutes = lazy(() => }), validator("param", Params), async (c) => { - return c.json(await Trigger.fire(c.req.valid("param").id)) + return c.json(await Trigger.fire(c.req.valid("param").id, "manual")) }, ) .post( @@ -141,7 +141,7 @@ export const TriggerRoutes = lazy(() => 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)) + return c.json(await Trigger.fire(id, "webhook")) }, ) .post( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 715f73f45db9..0a6e9c948655 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type TriggerLast = NonNullable export const SessionTable = sqliteTable( "session", @@ -117,6 +118,9 @@ export const TriggerTable = sqliteTable( 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(), }, diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 5dc017af2b6c..80b95ddc3b84 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -25,6 +25,18 @@ export namespace Trigger { }), ]) + 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(), @@ -36,6 +48,7 @@ export namespace Trigger { 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(), @@ -71,7 +84,7 @@ export namespace Trigger { create: (input: CreateInput) => Effect.Effect get: (id: string) => Effect.Effect list: () => Effect.Effect - fire: (id: string) => Effect.Effect + fire: (id: string, source: Source) => Effect.Effect enable: (id: string) => Effect.Effect disable: (id: string) => Effect.Effect delete: (id: string) => Effect.Effect @@ -81,7 +94,7 @@ export namespace Trigger { readonly create: (input: CreateInput) => Effect.Effect readonly get: (id: string) => Effect.Effect readonly list: () => Effect.Effect - readonly fire: (id: string) => 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 @@ -97,9 +110,12 @@ export namespace Trigger { 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.time.last ?? null, + time_last: item.last?.time ?? item.time.last ?? null, time_next: item.time.next, }) @@ -110,6 +126,16 @@ export namespace Trigger { ...(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 }), @@ -141,6 +167,15 @@ export namespace Trigger { 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() }) @@ -190,7 +225,21 @@ export namespace Trigger { }), ) - const run = Effect.fnUntraced(function* (item: Info) { + 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, @@ -209,32 +258,41 @@ export namespace Trigger { at, }) const action = item.action - if (!action) return next + if (!action) return yield* last(next, { source, status: "success", time: at }) const st = yield* Effect.promise(() => SessionStatus.get(action.sessionID)) - if (st.type !== "idle") return next - yield* Effect.promise(() => + 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.catchCause((cause) => - Effect.sync(() => - log.error("trigger action failed", { - triggerID: item.id, - cause: Cause.pretty(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, + }) + }), ), ) - return next }) const tick = Effect.fnUntraced(function* () { yield* Effect.forEach( Array.from(data.values()).filter((item) => item.enabled && item.time.next <= Date.now()), - (item) => run(item), + (item) => run(item, "schedule"), { discard: true }, ) }) @@ -282,8 +340,8 @@ export namespace Trigger { Effect.succeed(Array.from(data.values()).sort((a, b) => a.time.created - b.time.created)), ) - const fire = Effect.fn("Trigger.fire")(function* (id: string) { - return yield* run(yield* get(id)) + 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)) @@ -310,8 +368,8 @@ export namespace Trigger { list: Effect.fn("Trigger.list")(function* () { return yield* InstanceState.useEffect(state, (svc) => svc.list()) }), - fire: Effect.fn("Trigger.fire")(function* (id: string) { - return yield* InstanceState.useEffect(state, (svc) => svc.fire(id)) + 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)) @@ -345,8 +403,8 @@ export namespace Trigger { return runPromise((svc) => svc.enable(id)) } - export async function fire(id: string) { - return runPromise((svc) => svc.fire(id)) + export async function fire(id: string, source: Source = "manual") { + return runPromise((svc) => svc.fire(id, source)) } export async function disable(id: string) { diff --git a/packages/opencode/test/server/trigger.test.ts b/packages/opencode/test/server/trigger.test.ts index 7ad853067780..5c5031e2122f 100644 --- a/packages/opencode/test/server/trigger.test.ts +++ b/packages/opencode/test/server/trigger.test.ts @@ -141,6 +141,11 @@ describe("trigger routes", () => { 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), @@ -166,6 +171,11 @@ describe("trigger routes", () => { 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), diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 6471b45d729b..196314b11228 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -181,11 +181,18 @@ describe("trigger service", () => { 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), + }) }, }) }) @@ -214,7 +221,44 @@ describe("trigger service", () => { 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), + }) }, }) }) @@ -266,6 +310,11 @@ describe("trigger service", () => { at: last, }, ]) + expect(next.last).toMatchObject({ + source: "manual", + status: "success", + time: expect.any(Number), + }) }, }) }) From dcaf44a4dc0def66a56822d5251f3fa573426e32 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 17:57:42 -0700 Subject: [PATCH 34/40] feat: clarify remote fork targets --- .../component/dialog-remote-session-list.tsx | 21 ++- .../test/cli/tui/attach-startup.test.ts | 135 ++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) 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 index 9cba3e011e09..bd11b6cda2ae 100644 --- 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 @@ -10,6 +10,18 @@ type Session = { title?: string } +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 @@ -92,15 +104,12 @@ function DialogRemoteSessionBrowse(props: { root: Session; sessions: Session[]; const route = useRoute() const sdk = useSDK() const toast = useToast() + const browse = getRemoteBrowse(props) return ( ({ - title: item.title ?? item.id, - value: item.id, - footer: item.id, - }))} + title={browse.title} + options={browse.options} skipFilter={true} onSelect={(option) => { void openRemoteSession({ diff --git a/packages/opencode/test/cli/tui/attach-startup.test.ts b/packages/opencode/test/cli/tui/attach-startup.test.ts index 8ba070716c3e..39bab741e519 100644 --- a/packages/opencode/test/cli/tui/attach-startup.test.ts +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -5,6 +5,45 @@ afterEach(() => { }) describe("attach startup", () => { + 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"] @@ -159,4 +198,100 @@ describe("attach startup", () => { 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() + }) }) From ad1f8503bf2f0c37054294728720017b4c8c570e Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 20:25:14 -0700 Subject: [PATCH 35/40] feat: add one-shot trigger schedules --- packages/opencode/src/trigger/index.ts | 100 +++++++++++++----- .../opencode/test/trigger/trigger.test.ts | 88 +++++++++++++++ 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/trigger/index.ts b/packages/opencode/src/trigger/index.ts index 80b95ddc3b84..dc7cfb0a27fa 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -16,6 +16,24 @@ 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"), @@ -40,10 +58,7 @@ export namespace Trigger { export const Info = z .object({ id: z.string(), - schedule: z.object({ - type: z.literal("interval"), - interval: z.number().int().positive(), - }), + schedule: ScheduleInfo, action: Action.optional(), webhook_secret: z.string().min(1).optional(), enabled: z.boolean(), @@ -60,12 +75,40 @@ export namespace Trigger { }) export type Info = z.infer - export const CreateInput = z.object({ - interval: z.number().int().min(10).max(86_400_000), + const CreateBase = { action: Action.optional(), webhook_secret: z.string().min(1).optional(), - }) - export type CreateInput = z.infer + } + + 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( @@ -241,15 +284,26 @@ export namespace Trigger { const run = Effect.fnUntraced(function* (item: Info, source: Source) { const at = Date.now() - const next = { - ...item, - runs: item.runs + 1, - time: { - ...item.time, - last: at, - next: at + item.schedule.interval, - }, - } + 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, { @@ -308,19 +362,17 @@ export namespace Trigger { 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: { - type: "interval" as const, - interval: input.interval, - }, - action: input.action, - webhook_secret: input.webhook_secret, + schedule: cfg.schedule, + action: cfg.action, + webhook_secret: cfg.webhook_secret, enabled: true, runs: 0, time: { created: now, - next: now + input.interval, + next: cfg.schedule.type === "interval" ? now + cfg.schedule.interval : cfg.schedule.at, }, } satisfies Info data.set(item.id, item) diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 196314b11228..7bf0fdfcf2fb 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -158,6 +158,94 @@ describe("trigger service", () => { }) }) + 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 }) From 30fd48d4d39a855ae8530bfb5c3437aac937964d Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 20:32:25 -0700 Subject: [PATCH 36/40] feat: browse remote sessions from tui --- packages/opencode/src/cli/cmd/tui/app.tsx | 32 ++++- packages/opencode/src/cli/cmd/tui/attach.ts | 1 + .../component/dialog-remote-session-list.tsx | 42 ++++++ .../opencode/src/cli/cmd/tui/context/args.tsx | 1 + .../test/cli/tui/attach-startup.test.ts | 123 ++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6fa60339d848..72dc4c6fe813 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -31,7 +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, selectRemoteSession } from "@tui/component/dialog-remote-session-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" @@ -126,6 +130,21 @@ 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", @@ -368,6 +387,16 @@ function App(props: { onSnapshot?: () => 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) @@ -462,6 +491,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 2483fc0578c3..8a15b9a2d0d1 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -101,6 +101,7 @@ export const AttachCommand = cmd({ url: args.url, config, args: { + remote: true, continue: target.remoteSessions || target.picked ? false : args.continue, sessionID: target.picked ? target.baseID : args.session, fork: args.fork, 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 index bd11b6cda2ae..2e612fec8236 100644 --- 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 @@ -10,6 +10,48 @@ type Session = { 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", diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 4ab18c7a9af3..d8852e53c8be 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -4,6 +4,7 @@ export interface Args { model?: string agent?: string prompt?: string + remote?: boolean continue?: boolean sessionID?: string fork?: boolean diff --git a/packages/opencode/test/cli/tui/attach-startup.test.ts b/packages/opencode/test/cli/tui/attach-startup.test.ts index 39bab741e519..408feaef0b6a 100644 --- a/packages/opencode/test/cli/tui/attach-startup.test.ts +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -5,6 +5,129 @@ afterEach(() => { }) 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"] From 63828e266c2ed6a51206908f1f84a8b91a413340 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 22:56:14 -0700 Subject: [PATCH 37/40] feat: add opencode-memory skill and global session listing --- .opencode/skills/opencode-memory/SKILL.md | 274 ++++++++++++++++ ROADMAP.md | 310 +++++++----------- packages/opencode/src/cli/cmd/session.ts | 2 +- packages/opencode/src/session/index.ts | 193 ++++++++--- packages/opencode/src/storage/db.ts | 37 +++ .../test/server/global-session-list.test.ts | 90 +++++ 6 files changed, 668 insertions(+), 238 deletions(-) create mode 100644 .opencode/skills/opencode-memory/SKILL.md 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/ROADMAP.md b/ROADMAP.md index 3d1fc47470d6..2ebb7746612b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,201 +1,141 @@ # OpenCode Feature Roadmap (Claude Code Parity) -This roadmap outlines the 11 major features required to bring OpenCode up to parity with the leaked Claude Code capabilities. Features will be implemented in order. +This roadmap is now ordered by practical product priority rather than original discovery order. -## Phase 1: Core Agentic Capabilities +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` to replicate Anthropic's private `@ant/computer-use-swift`. Enables native OS control: mouse movement, keystrokes, and screen capture outside the terminal. + 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 allow OpenCode to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM (closing the gap with `webfetch`/`websearch`). + Integrate Playwright to navigate SPAs, execute JavaScript, click buttons, and read post-rendered DOM. - [x] **3. Dynamic Agent Swarms (SpawnMultiAgentTool)** - Implement Bun's native background workers to allow the main thread to spawn independent sub-agents for parallel task execution across multiple files or directories. + Implement Bun background workers so the main thread can spawn independent sub-agents for parallel task execution. - [x] **4. Long-Term Semantic Memory (SessionMemory)** - Implement a local SQLite vector store/database to persist user preferences, project architecture rules, and API keys across different terminal sessions. + Persist user preferences, project architecture rules, and other reusable context across sessions. - [x] **5. Strict Zod-Based Permission Gates (PermissionRouter)** - Implement a strict Zod schema layer for tool validation and a permission router with flags (`isReadOnly`, `isDestructive`). Add a secondary LLM classifier for automated risk assessment. - -## Phase 2: Experimental & Background Systems - -- [ ] **6. Buddy (Virtual Pet)** - Add a React/Ink component for an ASCII companion (duck, dragon, axolotl) that sits beside input and reacts dynamically to LLM confidence scores or bash success/failure rates. -- [ ] **7. Auto-Dream & AFK Mode** - Add an idle timer that spawns a background worker to consolidate session memory and review past context without burning active tokens while the user is away. -- [ ] **8. KAIROS & Daemon Mode (Proactive Agent)** - Extend the existing `--serve` headless mode into a true daemon that uses `cron` to wake up, fetch GitHub PRs, and proactively open review sessions. -- [ ] **9. Voice Mode** - Integrate local Whisper (or API) for speech-to-text input, and TTS for terminal audio output. -- [ ] **10. Bridge / Remote Control & Peer Discovery** - Enhance the existing `opencode attach` with mDNS broadcasting to allow Unix domain socket peer discovery and remote desktop environment sharing. -- [ ] **11. Specialized Modes (/advisor, /bughunter, /teleport)** - Add new slash commands. Implement `/advisor` by wrapping LLM diff outputs in an evaluation loop with a secondary model for QA grading. - -## Phase 3: Advanced Workflows & Context Management - -- [x] **12. Safe Git Sandboxing (EnterWorktreeTool / ExitWorktreeTool)** - Allow the agent to autonomously spawn a temporary Git Worktree (an isolated clone of the repo), do experimental coding there, test it, and only merge it back if it works. -- [x] **13. Background Task Orchestration (TaskCreateTool, TaskUpdateTool, TaskOutputTool)** - Allow the agent to kick off long-running terminal commands (like `npm run build` or `pytest`), push them to the background, and periodically check their status without blocking the chat UI. -- [ ] **14. Context Window Management (SnipTool & BriefTool)** - Implement `SnipTool` to autonomously permanently delete useless messages from the middle of the context window, and `BriefTool` to replace debugging loops with a 2-sentence summary. -- [ ] **15. Scheduled & Remote Triggers (ScheduleCronTool & RemoteTriggerTool)** - Allow the agent to create cron jobs to wake itself up, or expose a local webhook so external services can ping it to start working. + 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)** - Allow the agent to read CPU, memory usage, and active processes to diagnose system crashes or performance issues. - -## Phase 4: Missing Tools from Claude Code (High Priority) - -Based on analysis of free-code (54 working flags, 34 failed), these tools need implementation: - -### Core Tools -- [ ] **17. BriefTool (SendUserMessage)** - A tool for the agent to send messages directly to the user with optional file attachments. Supports proactive notifications when the user is away. Different from SendMessageTool - this is for brief UI mode. -- [ ] **18. SnipTool (HistorySnip)** - Tool to permanently remove specific messages from the context window to manage token budget. Different from compaction - this is surgical deletion of useless messages from the middle of context. -- [ ] **19. WorkflowTool** - Allow users to define reusable workflow scripts that combine multiple tool calls into a single command. Support bundled workflows and user-defined ones. -- [ ] **20. TerminalCaptureTool** - Capture and analyze terminal output for debugging and context understanding. Part of terminal panel feature. - -### System & Monitoring Tools -- [ ] **21. MonitorTool** - Read system metrics (CPU, memory, disk usage, active processes) to help diagnose performance issues or system crashes. -- [ ] **22. CtxInspectTool (ContextCollapse)** - Inspect and analyze the current context window state, helping users understand what's taking up tokens. - -### Agent Team Management -- [ ] **23. TeamCreateTool / TeamDeleteTool** - Create and manage named agent teams for swarming. Allows coordinated multi-agent workflows with shared context. Part of agent swarms feature. - -### Windows Support -- [ ] **24. PowerShellTool** - Full PowerShell support on Windows with proper permission handling, read-only validation, and security controls equivalent to BashTool. - -### Scheduling & Triggers -- [ ] **25. ScheduleCronTool (CronCreate/CronDelete/CronList)** - Create, delete, and list cron jobs that can trigger agent actions at scheduled times. -- [ ] **26. RemoteTriggerTool** - Expose a local webhook endpoint that external services (GitHub, Slack, etc.) can call to trigger agent actions. - -## Phase 5: Missing Slash Commands - -### Review & Analysis Commands -- [ ] **27. /advisor** - Configure a secondary "advisor" model that reviews the primary model's outputs for quality and suggests improvements. Wraps LLM diff outputs in an evaluation loop. -- [ ] **28. /bughunter** - Dedicated bug hunting mode that uses specialized prompts and tools to find security vulnerabilities and bugs. -- [ ] **29. /teleport** - Transfer the current session to Claude Code on the web (CCR) for continued work in a browser environment. Includes remote session management. -- [ ] **30. /ultraplan** - Advanced multi-agent planning mode that uses the most powerful model (Opus) to create detailed execution plans. ~10-30 min planning session in CCR. - -### Utility Commands -- [ ] **31. /voice** - Toggle voice mode for speech-to-text input and text-to-speech output. Requires audio backend (native module or SoX fallback). -- [ ] **32. /brief** - Toggle brief mode - changes the UI to show only brief messages from the agent instead of full tool outputs. Changes default view to 'chat'. -- [ ] **33. /proactive** - Enable proactive agent behavior - the agent will wake up on schedule to check for work (PRs, issues, etc). Requires AGENT_TRIGGERS flag. + 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** - Performance profiling and debugging command for analyzing slow operations. -- [ ] **35. /buddy** - Configure the ASCII companion/virtual pet (duck, dragon, axolotl) that reacts to session events. BUDDY flag. - -### Assistant & KAIROS Modes -- [ ] **36. /assistant** - Enter full KAIROS assistant mode - a different interaction model optimized for long-running background tasks with proactive behavior. -- [ ] **37. /brief command (KAIROS_BRIEF)** - Enable brief-only transcript layout without the full assistant stack. - -## Phase 6: Advanced Features & Infrastructure - -- [ ] **38. Context7 Integration for Libraries** - Deep documentation integration using Context7-compatible library IDs for major frameworks with up-to-date API references. -- [ ] **39. AST-Grep Integration** - Native AST-based code search and refactoring using ast-grep for pattern matching across 25+ languages. -- [ ] **40. MCP Rich Output** - Enhanced MCP tool result rendering with support for images, formatted data, and interactive elements. -- [ ] **41. Team Memory (TeamMem)** - Shared memory files for teams working on the same project, with automatic synchronization via watcher hooks. -- [ ] **42. Background Sessions (BG Sessions)** - Allow sessions to run fully in the background without a TUI, managed via CLI commands. BG_SESSIONS flag. -- [ ] **43. Commit Attribution** - Track and attribute which AI agent made specific changes in git history. COMMIT_ATTRIBUTION flag. -- [ ] **44. SSH Remote Support** - Connect to and work on remote machines via SSH with full tool support. SSH_REMOTE flag. -- [ ] **45. Direct Connect** - Peer-to-peer connection support for remote collaboration without going through cloud services. DIRECT_CONNECT flag. -- [ ] **46. Mobile Companion Support** - QR code generation and integration with mobile apps for remote control. CCR_MIRROR, CCR_AUTO_CONNECT support. -- [ ] **47. Chrome Extension Integration** - `claude-in-chrome` support for browser-based interactions and DOM manipulation. -- [ ] **48. Sandboxed Execution Mode** - Enhanced sandboxing with optional VM/isolation for untrusted code execution. -- [ ] **49. Self-Hosted Runner Support** - Deploy agents to self-hosted infrastructure for enterprise use. SELF_HOSTED_RUNNER flag. -- [ ] **50. Template System** - Project scaffolding and template system for quick project initialization. TEMPLATES flag. -- [ ] **51. Coordinator Mode** - Advanced multi-agent coordination with worker agent registry. COORDINATOR_MODE flag. -- [ ] **52. Reactive Compact** - Real-time context compaction based on usage patterns. REACTIVE_COMPACT flag. -- [ ] **53. Web Browser Tool** - Full browser automation tool distinct from the headless browser - allows user-guided browsing. -- [ ] **54. Verification Agent** - Built-in verification agent guidance in prompts for task/todo tooling. VERIFICATION_AGENT flag. -- [ ] **55. Extract Memories** - Post-query memory extraction hooks for automatic learning. EXTRACT_MEMORIES flag. -- [ ] **56. Cached Microcompact** - Cached microcompact state through query and API flows. CACHED_MICROCOMPACT flag. - -## Feature Implementation Notes - -### From free-code FEATURES.md Analysis -- **54 flags bundle cleanly** - these are user-facing or behavior-changing features -- **34 flags still fail to bundle** - these require more work to implement -- **Default build includes**: VOICE_MODE (bundles but needs OAuth + audio backend) - -### Priority Flags to Implement (Easy Reconstruction) -These have most of the surrounding code already in place: -- `AUTO_THEME` - Missing only `systemThemeWatcher.js` -- `BG_SESSIONS` - Missing only `bg.js` CLI fast-path -- `BUDDY` - Missing only `buddy/index.js` command entry -- `COMMIT_ATTRIBUTION` - Missing only `attributionHooks.js` -- `HISTORY_SNIP` - Missing only `force-snip.js` command -- `MCP_SKILLS` - Missing only `mcpSkills.js` registry layer - -### Priority Flags to Implement (Medium-Sized Gaps) -- `BYOC_ENVIRONMENT_RUNNER` - Environment runner main.js -- `CONTEXT_COLLAPSE` - CtxInspectTool implementation -- `COORDINATOR_MODE` - Coordinator worker agent system -- `DAEMON` - Worker registry for true daemon mode -- `DIRECT_CONNECT` - Parse connect URL logic -- `EXPERIMENTAL_SKILL_SEARCH` - Local skill search implementation -- `MONITOR_TOOL` - System monitoring tool -- `REACTIVE_COMPACT` - Reactive compaction service -- `REVIEW_ARTIFACT` - Hunter.js review system -- `SELF_HOSTED_RUNNER` - Self-hosted runner main.js -- `SSH_REMOTE` - SSH session creation -- `TERMINAL_PANEL` - TerminalCaptureTool -- `UDS_INBOX` - UDS messaging utilities -- `WEB_BROWSER_TOOL` - Web browser automation distinct from headless -- `WORKFLOW_SCRIPTS` - Workflow command and task implementation - -### Large Missing Subsystems -- `KAIROS` - Full assistant mode with `src/assistant/index.js` stack -- `KAIROS_DREAM` - Dream task behavior for AFK consolidation -- `PROACTIVE` - Proactive task/tool stack for daemon behavior + 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 / Experimental - -## Notes - -- Permission system uses Zod schemas with `isReadOnly`/`isDestructive` flags -- All tools should support proper TypeScript types and validation -- Slash commands follow the pattern in `src/commands.ts` -- Feature flags use `feature('FLAG_NAME')` pattern with bun:bundle -- Many experimental features are gated by GrowthBook or environment variables +- [!] Partially implemented / in flight 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/session/index.ts b/packages/opencode/src/session/index.ts index 94aee14c09c4..2e9a6f886502 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -183,6 +183,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", @@ -789,63 +908,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/storage/db.ts b/packages/opencode/src/storage/db.ts index f41a1ecd8549..205c6dc19e55 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" @@ -27,6 +28,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() { const channel = Installation.CHANNEL if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_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/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 }) From 6053fd6cc0890f12d9bc789785643e4b4d65c75b Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Tue, 31 Mar 2026 23:07:15 -0700 Subject: [PATCH 38/40] feat: add workspace ID support and webhook triggers --- packages/opencode/src/cli/cmd/remote.ts | 2 + packages/opencode/src/cli/cmd/run.ts | 5 ++ packages/opencode/src/cli/cmd/tui/app.tsx | 1 + packages/opencode/src/cli/cmd/tui/attach.ts | 6 ++ .../component/dialog-remote-session-list.tsx | 4 +- .../opencode/src/cli/cmd/tui/context/args.tsx | 1 + .../opencode/src/cli/cmd/tui/context/sdk.tsx | 3 +- packages/opencode/src/trigger/index.ts | 48 +++++++++--- .../test/cli/remote-preflight.test.ts | 55 +++++++++++++ .../opencode/test/trigger/trigger.test.ts | 77 +++++++++++++++++++ 10 files changed, 189 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 7a81fe30e9a7..0384b6268fa9 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -3,6 +3,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" type Input = { url: string directory?: string + workspaceID?: string headers?: RequestInit["headers"] fetch?: typeof globalThis.fetch } @@ -39,6 +40,7 @@ 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, }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5251e8fc1b34..48b28195fd95 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -290,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)", @@ -665,6 +669,7 @@ export const RunCommand = cmd({ const sdk = await preflightRemote({ url: args.attach, directory, + workspaceID: args.workspace, headers, }).catch((error) => { UI.error(error instanceof Error ? error.message : String(error)) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 72dc4c6fe813..f9a5338ebc40 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -230,6 +230,7 @@ export function tui(input: { { UI.error(error instanceof Error ? error.message : String(error)) @@ -104,6 +109,7 @@ export const AttachCommand = cmd({ 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, }, 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 index 2e612fec8236..5601c716f1ae 100644 --- 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 @@ -152,7 +152,7 @@ function DialogRemoteSessionBrowse(props: { root: Session; sessions: Session[]; { void openRemoteSession({ id: option.value, @@ -185,7 +185,7 @@ export function DialogRemoteSessionList(props: { sessions: Session[]; fork?: boo value: item.id, footer: item.id, }))} - skipFilter={true} + skipFilter={false} onSelect={(option) => { void selectRemoteSession({ id: option.value, diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index d8852e53c8be..9b971452adf2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -5,6 +5,7 @@ export interface Args { agent?: string prompt?: string remote?: boolean + workspaceID?: string continue?: boolean sessionID?: string fork?: boolean 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/trigger/index.ts b/packages/opencode/src/trigger/index.ts index dc7cfb0a27fa..64dc6a5b683f 100644 --- a/packages/opencode/src/trigger/index.ts +++ b/packages/opencode/src/trigger/index.ts @@ -41,6 +41,13 @@ export namespace Trigger { 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"]) @@ -313,16 +320,37 @@ export namespace Trigger { }) const action = item.action if (!action) return yield* last(next, { source, status: "success", time: at }) - 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 })), + + 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) diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index 52ec52c6168b..de84097435be 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -57,6 +57,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: false, session: undefined, fork: false, @@ -67,6 +68,52 @@ describe("remote preflight", () => { 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() @@ -96,6 +143,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: false, session: undefined, fork: false, @@ -144,6 +192,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: false, session: "missing", fork: false, @@ -190,6 +239,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: false, session: "sess_123", fork: false, @@ -235,6 +285,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: true, session: undefined, fork: false, @@ -298,6 +349,7 @@ describe("remote preflight", () => { $0: "opencode", url: "http://remote.test", dir: "/srv/app", + workspace: undefined, continue: true, session: undefined, fork: false, @@ -403,6 +455,7 @@ describe("remote preflight", () => { attach: "http://remote.test", password: undefined, dir: undefined, + workspace: undefined, port: undefined, variant: undefined, thinking: false, @@ -477,6 +530,7 @@ describe("remote preflight", () => { attach: "http://remote.test", password: undefined, dir: "/srv/app", + workspace: undefined, port: undefined, variant: undefined, thinking: false, @@ -552,6 +606,7 @@ describe("remote preflight", () => { attach: "http://remote.test", password: undefined, dir: "/srv/app", + workspace: undefined, port: undefined, variant: undefined, thinking: false, diff --git a/packages/opencode/test/trigger/trigger.test.ts b/packages/opencode/test/trigger/trigger.test.ts index 7bf0fdfcf2fb..55876cf43249 100644 --- a/packages/opencode/test/trigger/trigger.test.ts +++ b/packages/opencode/test/trigger/trigger.test.ts @@ -351,6 +351,83 @@ describe("trigger service", () => { }) }) + 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 }) From 559d4a984d9466e9a578ce40f29fd128d0ed314b Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Wed, 1 Apr 2026 08:25:11 -0700 Subject: [PATCH 39/40] feat: add mobile-friendly remote control and trigger management --- PLAN.md | 68 ++++++ README.md | 50 ++++ .../settings-general.helpers.test.ts | 20 ++ .../components/settings-general.helpers.ts | 33 +++ .../app/src/components/settings-general.tsx | 32 +++ packages/app/src/context/platform.tsx | 3 + packages/app/src/entry.tsx | 23 +- packages/app/src/pages/layout.tsx | 118 ++++++++- packages/app/src/pages/layout/helpers.test.ts | 102 ++++++++ packages/app/src/pages/layout/helpers.ts | 64 ++++- .../app/src/pages/layout/sidebar-items.tsx | 51 +++- packages/app/src/pages/session.tsx | 42 +++- .../app/src/pages/session/helpers.test.ts | 32 +++ packages/app/src/pages/session/helpers.ts | 9 + packages/opencode/src/cli/cmd/trigger.ts | 227 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + packages/opencode/test/cli/trigger.test.ts | 80 ++++++ packages/ui/src/components/message-part.css | 58 +++++ packages/web/src/content/docs/cli.mdx | 73 +++++- packages/web/src/content/docs/server.mdx | 67 ++++++ 20 files changed, 1131 insertions(+), 23 deletions(-) create mode 100644 PLAN.md create mode 100644 packages/app/src/components/settings-general.helpers.test.ts create mode 100644 packages/app/src/components/settings-general.helpers.ts create mode 100644 packages/opencode/src/cli/cmd/trigger.ts create mode 100644 packages/opencode/test/cli/trigger.test.ts 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/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/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/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/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/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 | From 5c632f6c460a0d459704dab7f7b3c28e520a6551 Mon Sep 17 00:00:00 2001 From: Jai Radhakrishnan Date: Wed, 1 Apr 2026 09:02:16 -0700 Subject: [PATCH 40/40] feat: replace external spinner with custom implementation --- .../cli/cmd/tui/component/prompt/index.tsx | 25 ++---------------- .../src/cli/cmd/tui/component/spinner.tsx | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 27 deletions(-) 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}