mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6718131e | |||
| 00d1a7e090 | |||
| bbb56c2a88 | |||
| 5186c6964b | |||
| 79c66e353f | |||
| 41f5e8a861 | |||
| c5b67927af | |||
| 301ecb185e | |||
| 151df05eeb | |||
| 55adcdfd07 | |||
| daaa2e5911 | |||
| daff119fe4 | |||
| e0d1ff42c0 | |||
| de413c56ae | |||
| da61b0290a | |||
| d03e6cedde | |||
| 7feb6ab962 | |||
| 854d4b7a53 | |||
| aa5999b188 | |||
| 37c5eab6f8 | |||
| 6daa2b9aeb | |||
| 5a5a2e5fa0 | |||
| 95c43fc675 | |||
| e7053c41f4 | |||
| fc6d4b4010 | |||
| 2893588016 | |||
| f2d4d816fb | |||
| 097d930668 | |||
| 7cab6824d1 | |||
| f77277a69e | |||
| 450128f9be | |||
| 3e35c974a4 | |||
| a14c22d4e9 | |||
| 58c65874ba | |||
| 27b0877714 | |||
| 5904f599a9 | |||
| df9e1d9854 | |||
| 75a22f82bd | |||
| a369130226 | |||
| 474024f9e6 | |||
| b4f4134e81 | |||
| cd64b67038 | |||
| 9af46df535 | |||
| b749866f0b | |||
| 60fa708f0b | |||
| 3b74077437 | |||
| 95d4bb2130 | |||
| f5dce6d960 | |||
| 1e98167b0e | |||
| 3eee2f6afa | |||
| ff4b60e1f3 | |||
| f91b73b938 | |||
| 05661c60ff | |||
| 625aca49de | |||
| 3bc0c36ace | |||
| eb0219988b |
@@ -51,7 +51,7 @@ jobs:
|
||||
PR_TITLE: ${{ steps.pr-details.outputs.title }}
|
||||
run: |
|
||||
PR_BODY=$(jq -r .body pr_data.json)
|
||||
opencode run -m opencode/openai-gpt-5.5 --variant medium "A new pull request has been created: '${PR_TITLE}'
|
||||
opencode run -m opencode/gpt-5.5 --variant medium "A new pull request has been created: '${PR_TITLE}'
|
||||
|
||||
<pr-number>
|
||||
${{ steps.pr-number.outputs.number }}
|
||||
|
||||
@@ -1,899 +0,0 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gpt-5.4
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
|
||||
|
||||
Requirements:
|
||||
|
||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Do not modify fenced code blocks.
|
||||
- Output ONLY the translation (no commentary).
|
||||
|
||||
If the target locale is missing, ask the user to provide it.
|
||||
If no locale-specific glossary exists, use the global glossary only.
|
||||
|
||||
---
|
||||
|
||||
# Locale-Specific Glossaries
|
||||
|
||||
When a locale glossary exists, use it to:
|
||||
|
||||
- Apply preferred wording for recurring UI/docs terms in that locale
|
||||
- Preserve locale-specific do-not-translate terms and casing decisions
|
||||
- Prefer natural phrasing over literal translation when the locale file calls it out
|
||||
- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo)
|
||||
|
||||
Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below.
|
||||
|
||||
---
|
||||
|
||||
# Do-Not-Translate Terms (OpenCode Docs)
|
||||
|
||||
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
|
||||
Generated on: 2026-02-10
|
||||
|
||||
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
|
||||
|
||||
General rules (verbatim, even if not listed below):
|
||||
|
||||
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
|
||||
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
|
||||
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
|
||||
|
||||
## Proper nouns and product names
|
||||
|
||||
Additional (not reliably captured via link text):
|
||||
|
||||
```text
|
||||
Astro
|
||||
Bun
|
||||
Chocolatey
|
||||
Cursor
|
||||
Docker
|
||||
Git
|
||||
GitHub Actions
|
||||
GitLab CI
|
||||
GNOME Terminal
|
||||
Homebrew
|
||||
Mise
|
||||
Neovim
|
||||
Node.js
|
||||
npm
|
||||
Obsidian
|
||||
opencode
|
||||
opencode-ai
|
||||
Paru
|
||||
pnpm
|
||||
ripgrep
|
||||
Scoop
|
||||
SST
|
||||
Starlight
|
||||
Visual Studio Code
|
||||
VS Code
|
||||
VSCodium
|
||||
Windsurf
|
||||
Windows Terminal
|
||||
Yarn
|
||||
Zellij
|
||||
Zed
|
||||
anomalyco
|
||||
```
|
||||
|
||||
Extracted from link labels in the English docs (review and prune as desired):
|
||||
|
||||
```text
|
||||
@openspoon/subtask2
|
||||
302.AI console
|
||||
ACP progress report
|
||||
Agent Client Protocol
|
||||
Agent Skills
|
||||
Agentic
|
||||
AGENTS.md
|
||||
AI SDK
|
||||
Alacritty
|
||||
Anthropic
|
||||
Anthropic's Data Policies
|
||||
Atom One
|
||||
Avante.nvim
|
||||
Ayu
|
||||
Azure AI Foundry
|
||||
Azure portal
|
||||
Baseten
|
||||
built-in GITHUB_TOKEN
|
||||
Bun.$
|
||||
Catppuccin
|
||||
Cerebras console
|
||||
ChatGPT Plus or Pro
|
||||
Cloudflare dashboard
|
||||
CodeCompanion.nvim
|
||||
CodeNomad
|
||||
Configuring Adapters: Environment Variables
|
||||
Context7 MCP server
|
||||
Cortecs console
|
||||
Deep Infra dashboard
|
||||
DeepSeek console
|
||||
Duo Agent Platform
|
||||
Everforest
|
||||
Fireworks AI console
|
||||
Firmware dashboard
|
||||
Ghostty
|
||||
GitLab CLI agents docs
|
||||
GitLab docs
|
||||
GitLab User Settings > Access Tokens
|
||||
Granular Rules (Object Syntax)
|
||||
Grep by Vercel
|
||||
Groq console
|
||||
Gruvbox
|
||||
Helicone
|
||||
Helicone documentation
|
||||
Helicone Header Directory
|
||||
Helicone's Model Directory
|
||||
Hugging Face Inference Providers
|
||||
Hugging Face settings
|
||||
install WSL
|
||||
IO.NET console
|
||||
JetBrains IDE
|
||||
Kanagawa
|
||||
Kitty
|
||||
MiniMax API Console
|
||||
Models.dev
|
||||
Moonshot AI console
|
||||
Nebius Token Factory console
|
||||
Nord
|
||||
OAuth
|
||||
Ollama integration docs
|
||||
OpenAI's Data Policies
|
||||
OpenChamber
|
||||
OpenCode
|
||||
OpenCode config
|
||||
OpenCode Config
|
||||
OpenCode TUI with the opencode theme
|
||||
OpenCode Web - Active Session
|
||||
OpenCode Web - New Session
|
||||
OpenCode Web - See Servers
|
||||
OpenCode Zen
|
||||
OpenCode-Obsidian
|
||||
OpenRouter dashboard
|
||||
OpenWork
|
||||
OVHcloud panel
|
||||
Pro+ subscription
|
||||
SAP BTP Cockpit
|
||||
Scaleway Console IAM settings
|
||||
Scaleway Generative APIs
|
||||
SDK documentation
|
||||
Sentry MCP server
|
||||
shell API
|
||||
Together AI console
|
||||
Tokyonight
|
||||
Unified Billing
|
||||
Venice AI console
|
||||
Vercel dashboard
|
||||
WezTerm
|
||||
Windows Subsystem for Linux (WSL)
|
||||
WSL
|
||||
WSL (Windows Subsystem for Linux)
|
||||
WSL extension
|
||||
xAI console
|
||||
Z.AI API console
|
||||
Zed
|
||||
ZenMux dashboard
|
||||
Zod
|
||||
```
|
||||
|
||||
## Acronyms and initialisms
|
||||
|
||||
```text
|
||||
ACP
|
||||
AGENTS
|
||||
AI
|
||||
AI21
|
||||
ANSI
|
||||
API
|
||||
AST
|
||||
AWS
|
||||
BTP
|
||||
CD
|
||||
CDN
|
||||
CI
|
||||
CLI
|
||||
CMD
|
||||
CORS
|
||||
DEBUG
|
||||
EKS
|
||||
ERROR
|
||||
FAQ
|
||||
GLM
|
||||
GNOME
|
||||
GPT
|
||||
HTML
|
||||
HTTP
|
||||
HTTPS
|
||||
IAM
|
||||
ID
|
||||
IDE
|
||||
INFO
|
||||
IO
|
||||
IP
|
||||
IRSA
|
||||
JS
|
||||
JSON
|
||||
JSONC
|
||||
K2
|
||||
LLM
|
||||
LM
|
||||
LSP
|
||||
M2
|
||||
MCP
|
||||
MR
|
||||
NET
|
||||
NPM
|
||||
NTLM
|
||||
OIDC
|
||||
OS
|
||||
PAT
|
||||
PATH
|
||||
PHP
|
||||
PR
|
||||
PTY
|
||||
README
|
||||
RFC
|
||||
RPC
|
||||
SAP
|
||||
SDK
|
||||
SKILL
|
||||
SSE
|
||||
SSO
|
||||
TS
|
||||
TTY
|
||||
TUI
|
||||
UI
|
||||
URL
|
||||
US
|
||||
UX
|
||||
VCS
|
||||
VPC
|
||||
VPN
|
||||
VS
|
||||
WARN
|
||||
WSL
|
||||
X11
|
||||
YAML
|
||||
```
|
||||
|
||||
## Code identifiers used in prose (CamelCase, mixedCase)
|
||||
|
||||
```text
|
||||
apiKey
|
||||
AppleScript
|
||||
AssistantMessage
|
||||
baseURL
|
||||
BurntSushi
|
||||
ChatGPT
|
||||
ClangFormat
|
||||
CodeCompanion
|
||||
CodeNomad
|
||||
DeepSeek
|
||||
DefaultV2
|
||||
FileContent
|
||||
FileDiff
|
||||
FileNode
|
||||
fineGrained
|
||||
FormatterStatus
|
||||
GitHub
|
||||
GitLab
|
||||
iTerm2
|
||||
JavaScript
|
||||
JetBrains
|
||||
macOS
|
||||
mDNS
|
||||
MiniMax
|
||||
NeuralNomadsAI
|
||||
NickvanDyke
|
||||
NoeFabris
|
||||
OpenAI
|
||||
OpenAPI
|
||||
OpenChamber
|
||||
OpenCode
|
||||
OpenRouter
|
||||
OpenTUI
|
||||
OpenWork
|
||||
ownUserPermissions
|
||||
PowerShell
|
||||
ProviderAuthAuthorization
|
||||
ProviderAuthMethod
|
||||
ProviderInitError
|
||||
SessionStatus
|
||||
TabItem
|
||||
tokenType
|
||||
ToolIDs
|
||||
ToolList
|
||||
TypeScript
|
||||
typesUrl
|
||||
UserMessage
|
||||
VcsInfo
|
||||
WebView2
|
||||
WezTerm
|
||||
xAI
|
||||
ZenMux
|
||||
```
|
||||
|
||||
## OpenCode CLI commands (as shown in docs)
|
||||
|
||||
```text
|
||||
opencode
|
||||
opencode [project]
|
||||
opencode /path/to/project
|
||||
opencode acp
|
||||
opencode agent [command]
|
||||
opencode agent create
|
||||
opencode agent list
|
||||
opencode attach [url]
|
||||
opencode attach http://10.20.30.40:4096
|
||||
opencode attach http://localhost:4096
|
||||
opencode auth [command]
|
||||
opencode auth list
|
||||
opencode auth login
|
||||
opencode auth logout
|
||||
opencode auth ls
|
||||
opencode export [sessionID]
|
||||
opencode github [command]
|
||||
opencode github install
|
||||
opencode github run
|
||||
opencode import <file>
|
||||
opencode import https://opncd.ai/s/abc123
|
||||
opencode import session.json
|
||||
opencode mcp [command]
|
||||
opencode mcp add
|
||||
opencode mcp auth [name]
|
||||
opencode mcp auth list
|
||||
opencode mcp auth ls
|
||||
opencode mcp auth my-oauth-server
|
||||
opencode mcp auth sentry
|
||||
opencode mcp debug <name>
|
||||
opencode mcp debug my-oauth-server
|
||||
opencode mcp list
|
||||
opencode mcp logout [name]
|
||||
opencode mcp logout my-oauth-server
|
||||
opencode mcp ls
|
||||
opencode models --refresh
|
||||
opencode models [provider]
|
||||
opencode models anthropic
|
||||
opencode run [message..]
|
||||
opencode run Explain the use of context in Go
|
||||
opencode serve
|
||||
opencode serve --cors http://localhost:5173 --cors https://app.example.com
|
||||
opencode serve --hostname 0.0.0.0 --port 4096
|
||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||
opencode session [command]
|
||||
opencode session list
|
||||
opencode session delete <sessionID>
|
||||
opencode stats
|
||||
opencode uninstall
|
||||
opencode upgrade
|
||||
opencode upgrade [target]
|
||||
opencode upgrade v0.1.48
|
||||
opencode web
|
||||
opencode web --cors https://example.com
|
||||
opencode web --hostname 0.0.0.0
|
||||
opencode web --mdns
|
||||
opencode web --mdns --mdns-domain myproject.local
|
||||
opencode web --port 4096
|
||||
opencode web --port 4096 --hostname 0.0.0.0
|
||||
opencode.server.close()
|
||||
```
|
||||
|
||||
## Slash commands and routes
|
||||
|
||||
```text
|
||||
/agent
|
||||
/auth/:id
|
||||
/clear
|
||||
/command
|
||||
/config
|
||||
/config/providers
|
||||
/connect
|
||||
/continue
|
||||
/doc
|
||||
/editor
|
||||
/event
|
||||
/experimental/tool?provider=<p>&model=<m>
|
||||
/experimental/tool/ids
|
||||
/export
|
||||
/file?path=<path>
|
||||
/file/content?path=<p>
|
||||
/file/status
|
||||
/find?pattern=<pat>
|
||||
/find/file
|
||||
/find/file?query=<q>
|
||||
/find/symbol?query=<q>
|
||||
/formatter
|
||||
/global/event
|
||||
/global/health
|
||||
/help
|
||||
/init
|
||||
/instance/dispose
|
||||
/log
|
||||
/lsp
|
||||
/mcp
|
||||
/mnt/
|
||||
/mnt/c/
|
||||
/mnt/d/
|
||||
/models
|
||||
/oc
|
||||
/opencode
|
||||
/path
|
||||
/project
|
||||
/project/current
|
||||
/provider
|
||||
/provider/{id}/oauth/authorize
|
||||
/provider/{id}/oauth/callback
|
||||
/provider/auth
|
||||
/q
|
||||
/quit
|
||||
/redo
|
||||
/resume
|
||||
/session
|
||||
/session/:id
|
||||
/session/:id/abort
|
||||
/session/:id/children
|
||||
/session/:id/command
|
||||
/session/:id/diff
|
||||
/session/:id/fork
|
||||
/session/:id/init
|
||||
/session/:id/message
|
||||
/session/:id/message/:messageID
|
||||
/session/:id/permissions/:permissionID
|
||||
/session/:id/prompt_async
|
||||
/session/:id/revert
|
||||
/session/:id/share
|
||||
/session/:id/shell
|
||||
/session/:id/summarize
|
||||
/session/:id/todo
|
||||
/session/:id/unrevert
|
||||
/session/status
|
||||
/share
|
||||
/summarize
|
||||
/theme
|
||||
/tui
|
||||
/tui/append-prompt
|
||||
/tui/clear-prompt
|
||||
/tui/control/next
|
||||
/tui/control/response
|
||||
/tui/execute-command
|
||||
/tui/open-help
|
||||
/tui/open-models
|
||||
/tui/open-sessions
|
||||
/tui/open-themes
|
||||
/tui/show-toast
|
||||
/tui/submit-prompt
|
||||
/undo
|
||||
/Users/username
|
||||
/Users/username/projects/*
|
||||
/vcs
|
||||
```
|
||||
|
||||
## CLI flags and short options
|
||||
|
||||
```text
|
||||
--agent
|
||||
--attach
|
||||
--command
|
||||
--continue
|
||||
--cors
|
||||
--cwd
|
||||
--days
|
||||
--dir
|
||||
--dry-run
|
||||
--event
|
||||
--file
|
||||
--force
|
||||
--fork
|
||||
--format
|
||||
--help
|
||||
--hostname
|
||||
--hostname 0.0.0.0
|
||||
--keep-config
|
||||
--keep-data
|
||||
--log-level
|
||||
--max-count
|
||||
--mdns
|
||||
--mdns-domain
|
||||
--method
|
||||
--model
|
||||
--models
|
||||
--port
|
||||
--print-logs
|
||||
--project
|
||||
--prompt
|
||||
--refresh
|
||||
--session
|
||||
--share
|
||||
--title
|
||||
--token
|
||||
--tools
|
||||
--verbose
|
||||
--version
|
||||
--wait
|
||||
|
||||
-c
|
||||
-d
|
||||
-f
|
||||
-h
|
||||
-m
|
||||
-n
|
||||
-s
|
||||
-v
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
```text
|
||||
AI_API_URL
|
||||
AI_FLOW_CONTEXT
|
||||
AI_FLOW_EVENT
|
||||
AI_FLOW_INPUT
|
||||
AICORE_DEPLOYMENT_ID
|
||||
AICORE_RESOURCE_GROUP
|
||||
AICORE_SERVICE_KEY
|
||||
ANTHROPIC_API_KEY
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_BEARER_TOKEN_BEDROCK
|
||||
AWS_PROFILE
|
||||
AWS_REGION
|
||||
AWS_ROLE_ARN
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
AZURE_RESOURCE_NAME
|
||||
CI_PROJECT_DIR
|
||||
CI_SERVER_FQDN
|
||||
CI_WORKLOAD_REF
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
CLOUDFLARE_API_TOKEN
|
||||
CLOUDFLARE_GATEWAY_ID
|
||||
CONTEXT7_API_KEY
|
||||
GITHUB_TOKEN
|
||||
GITLAB_AI_GATEWAY_URL
|
||||
GITLAB_HOST
|
||||
GITLAB_INSTANCE_URL
|
||||
GITLAB_OAUTH_CLIENT_ID
|
||||
GITLAB_TOKEN
|
||||
GITLAB_TOKEN_OPENCODE
|
||||
GOOGLE_APPLICATION_CREDENTIALS
|
||||
GOOGLE_CLOUD_PROJECT
|
||||
HTTP_PROXY
|
||||
HTTPS_PROXY
|
||||
K2_
|
||||
MY_API_KEY
|
||||
MY_ENV_VAR
|
||||
MY_MCP_CLIENT_ID
|
||||
MY_MCP_CLIENT_SECRET
|
||||
NO_PROXY
|
||||
NODE_ENV
|
||||
NODE_EXTRA_CA_CERTS
|
||||
NPM_AUTH_TOKEN
|
||||
OC_ALLOW_WAYLAND
|
||||
OPENCODE_API_KEY
|
||||
OPENCODE_AUTH_JSON
|
||||
OPENCODE_AUTO_SHARE
|
||||
OPENCODE_CLIENT
|
||||
OPENCODE_CONFIG
|
||||
OPENCODE_CONFIG_CONTENT
|
||||
OPENCODE_CONFIG_DIR
|
||||
OPENCODE_DISABLE_AUTOCOMPACT
|
||||
OPENCODE_DISABLE_AUTOUPDATE
|
||||
OPENCODE_DISABLE_CLAUDE_CODE
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||
OPENCODE_DISABLE_MODELS_FETCH
|
||||
OPENCODE_DISABLE_PRUNE
|
||||
OPENCODE_DISABLE_TERMINAL_TITLE
|
||||
OPENCODE_ENABLE_EXA
|
||||
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
|
||||
OPENCODE_EXPERIMENTAL
|
||||
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
OPENCODE_EXPERIMENTAL_EXA
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||
OPENCODE_EXPERIMENTAL_OXFMT
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||
OPENCODE_ENABLE_QUESTION_TOOL
|
||||
OPENCODE_FAKE_VCS
|
||||
OPENCODE_GIT_BASH_PATH
|
||||
OPENCODE_MODEL
|
||||
OPENCODE_MODELS_URL
|
||||
OPENCODE_PERMISSION
|
||||
OPENCODE_PORT
|
||||
OPENCODE_SERVER_PASSWORD
|
||||
OPENCODE_SERVER_USERNAME
|
||||
PROJECT_ROOT
|
||||
RESOURCE_NAME
|
||||
RUST_LOG
|
||||
VARIABLE_NAME
|
||||
VERTEX_LOCATION
|
||||
XDG_CONFIG_HOME
|
||||
```
|
||||
|
||||
## Package/module identifiers
|
||||
|
||||
```text
|
||||
../../../config.mjs
|
||||
@astrojs/starlight/components
|
||||
@opencode-ai/plugin
|
||||
@opencode-ai/sdk
|
||||
path
|
||||
shescape
|
||||
zod
|
||||
|
||||
@
|
||||
@ai-sdk/anthropic
|
||||
@ai-sdk/cerebras
|
||||
@ai-sdk/google
|
||||
@ai-sdk/openai
|
||||
@ai-sdk/openai-compatible
|
||||
@File#L37-42
|
||||
@modelcontextprotocol/server-everything
|
||||
@opencode
|
||||
```
|
||||
|
||||
## GitHub owner/repo slugs referenced in docs
|
||||
|
||||
```text
|
||||
24601/opencode-zellij-namer
|
||||
angristan/opencode-wakatime
|
||||
anomalyco/opencode
|
||||
apps/opencode-agent
|
||||
athal7/opencode-devcontainers
|
||||
awesome-opencode/awesome-opencode
|
||||
backnotprop/plannotator
|
||||
ben-vargas/ai-sdk-provider-opencode-sdk
|
||||
btriapitsyn/openchamber
|
||||
BurntSushi/ripgrep
|
||||
Cluster444/agentic
|
||||
code-yeongyu/oh-my-opencode
|
||||
darrenhinde/opencode-agents
|
||||
different-ai/opencode-scheduler
|
||||
different-ai/openwork
|
||||
features/copilot
|
||||
folke/tokyonight.nvim
|
||||
franlol/opencode-md-table-formatter
|
||||
ggml-org/llama.cpp
|
||||
ghoulr/opencode-websearch-cited.git
|
||||
H2Shami/opencode-helicone-session
|
||||
hosenur/portal
|
||||
jamesmurdza/daytona
|
||||
jenslys/opencode-gemini-auth
|
||||
JRedeker/opencode-morph-fast-apply
|
||||
JRedeker/opencode-shell-strategy
|
||||
kdcokenny/ocx
|
||||
kdcokenny/opencode-background-agents
|
||||
kdcokenny/opencode-notify
|
||||
kdcokenny/opencode-workspace
|
||||
kdcokenny/opencode-worktree
|
||||
login/device
|
||||
mohak34/opencode-notifier
|
||||
morhetz/gruvbox
|
||||
mtymek/opencode-obsidian
|
||||
NeuralNomadsAI/CodeNomad
|
||||
nick-vi/opencode-type-inject
|
||||
NickvanDyke/opencode.nvim
|
||||
NoeFabris/opencode-antigravity-auth
|
||||
nordtheme/nord
|
||||
numman-ali/opencode-openai-codex-auth
|
||||
olimorris/codecompanion.nvim
|
||||
panta82/opencode-notificator
|
||||
rebelot/kanagawa.nvim
|
||||
remorses/kimaki
|
||||
sainnhe/everforest
|
||||
shekohex/opencode-google-antigravity-auth
|
||||
shekohex/opencode-pty.git
|
||||
spoons-and-mirrors/subtask2
|
||||
sudo-tee/opencode.nvim
|
||||
supermemoryai/opencode-supermemory
|
||||
Tarquinen/opencode-dynamic-context-pruning
|
||||
Th3Whit3Wolf/one-nvim
|
||||
upstash/context7
|
||||
vtemian/micode
|
||||
vtemian/octto
|
||||
yetone/avante.nvim
|
||||
zenobi-us/opencode-plugin-template
|
||||
zenobi-us/opencode-skillful
|
||||
```
|
||||
|
||||
## Paths, filenames, globs, and URLs
|
||||
|
||||
```text
|
||||
./.opencode/themes/*.json
|
||||
./<project-slug>/storage/
|
||||
./config/#custom-directory
|
||||
./global/storage/
|
||||
.agents/skills/*/SKILL.md
|
||||
.agents/skills/<name>/SKILL.md
|
||||
.clang-format
|
||||
.claude
|
||||
.claude/skills
|
||||
.claude/skills/*/SKILL.md
|
||||
.claude/skills/<name>/SKILL.md
|
||||
.env
|
||||
.github/workflows/opencode.yml
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.ignore
|
||||
.NET SDK
|
||||
.npmrc
|
||||
.ocamlformat
|
||||
.opencode
|
||||
.opencode/
|
||||
.opencode/agents/
|
||||
.opencode/commands/
|
||||
.opencode/commands/test.md
|
||||
.opencode/modes/
|
||||
.opencode/plans/*.md
|
||||
.opencode/plugins/
|
||||
.opencode/skills/<name>/SKILL.md
|
||||
.opencode/skills/git-release/SKILL.md
|
||||
.opencode/tools/
|
||||
.well-known/opencode
|
||||
{ type: "raw" \| "patch", content: string }
|
||||
{file:path/to/file}
|
||||
**/*.js
|
||||
%USERPROFILE%/intelephense/license.txt
|
||||
%USERPROFILE%\.cache\opencode
|
||||
%USERPROFILE%\.config\opencode\opencode.jsonc
|
||||
%USERPROFILE%\.config\opencode\plugins
|
||||
%USERPROFILE%\.local\share\opencode
|
||||
%USERPROFILE%\.local\share\opencode\log
|
||||
<project-root>/.opencode/themes/*.json
|
||||
<providerId>/<modelId>
|
||||
<your-project>/.opencode/plugins/
|
||||
~
|
||||
~/...
|
||||
~/.agents/skills/*/SKILL.md
|
||||
~/.agents/skills/<name>/SKILL.md
|
||||
~/.aws/credentials
|
||||
~/.bashrc
|
||||
~/.cache/opencode
|
||||
~/.cache/opencode/node_modules/
|
||||
~/.claude/CLAUDE.md
|
||||
~/.claude/skills/
|
||||
~/.claude/skills/*/SKILL.md
|
||||
~/.claude/skills/<name>/SKILL.md
|
||||
~/.config/opencode
|
||||
~/.config/opencode/AGENTS.md
|
||||
~/.config/opencode/agents/
|
||||
~/.config/opencode/commands/
|
||||
~/.config/opencode/modes/
|
||||
~/.config/opencode/opencode.json
|
||||
~/.config/opencode/opencode.jsonc
|
||||
~/.config/opencode/plugins/
|
||||
~/.config/opencode/skills/*/SKILL.md
|
||||
~/.config/opencode/skills/<name>/SKILL.md
|
||||
~/.config/opencode/themes/*.json
|
||||
~/.config/opencode/tools/
|
||||
~/.config/zed/settings.json
|
||||
~/.local/share
|
||||
~/.local/share/opencode/
|
||||
~/.local/share/opencode/auth.json
|
||||
~/.local/share/opencode/log/
|
||||
~/.local/share/opencode/mcp-auth.json
|
||||
~/.local/share/opencode/opencode.jsonc
|
||||
~/.npmrc
|
||||
~/.zshrc
|
||||
~/code/
|
||||
~/Library/Application Support
|
||||
~/projects/*
|
||||
~/projects/personal/
|
||||
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
|
||||
$HOME/intelephense/license.txt
|
||||
$HOME/projects/*
|
||||
$XDG_CONFIG_HOME/opencode/themes/*.json
|
||||
agent/
|
||||
agents/
|
||||
build/
|
||||
commands/
|
||||
dist/
|
||||
http://<wsl-ip>:4096
|
||||
http://127.0.0.1:8080/callback
|
||||
http://localhost:<port>
|
||||
http://localhost:4096
|
||||
http://localhost:4096/doc
|
||||
https://app.example.com
|
||||
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
|
||||
https://opencode.ai/zen/v1/chat/completions
|
||||
https://opencode.ai/zen/v1/messages
|
||||
https://opencode.ai/zen/v1/models/gemini-3-flash
|
||||
https://opencode.ai/zen/v1/models/gemini-3-pro
|
||||
https://opencode.ai/zen/v1/responses
|
||||
https://RESOURCE_NAME.openai.azure.com/
|
||||
laravel/pint
|
||||
log/
|
||||
model: "anthropic/claude-sonnet-4-5"
|
||||
modes/
|
||||
node_modules/
|
||||
openai/gpt-4.1
|
||||
opencode.ai/config.json
|
||||
opencode/<model-id>
|
||||
opencode/gpt-5.1-codex
|
||||
opencode/gpt-5.2-codex
|
||||
opencode/kimi-k2
|
||||
openrouter/google/gemini-2.5-flash
|
||||
opncd.ai/s/<share-id>
|
||||
packages/*/AGENTS.md
|
||||
plugins/
|
||||
project/
|
||||
provider_id/model_id
|
||||
provider/model
|
||||
provider/model-id
|
||||
rm -rf ~/.cache/opencode
|
||||
skills/
|
||||
skills/*/SKILL.md
|
||||
src/**/*.ts
|
||||
themes/
|
||||
tools/
|
||||
```
|
||||
|
||||
## Keybind strings
|
||||
|
||||
```text
|
||||
alt+b
|
||||
Alt+Ctrl+K
|
||||
alt+d
|
||||
alt+f
|
||||
Cmd+Esc
|
||||
Cmd+Option+K
|
||||
Cmd+Shift+Esc
|
||||
Cmd+Shift+G
|
||||
Cmd+Shift+P
|
||||
ctrl+a
|
||||
ctrl+b
|
||||
ctrl+d
|
||||
ctrl+e
|
||||
Ctrl+Esc
|
||||
ctrl+f
|
||||
ctrl+g
|
||||
ctrl+k
|
||||
Ctrl+Shift+Esc
|
||||
Ctrl+Shift+P
|
||||
ctrl+t
|
||||
ctrl+u
|
||||
ctrl+w
|
||||
ctrl+x
|
||||
DELETE
|
||||
Shift+Enter
|
||||
WIN+R
|
||||
```
|
||||
|
||||
## Model ID strings referenced
|
||||
|
||||
```text
|
||||
{env:OPENCODE_MODEL}
|
||||
anthropic/claude-3-5-sonnet-20241022
|
||||
anthropic/claude-haiku-4-20250514
|
||||
anthropic/claude-haiku-4-5
|
||||
anthropic/claude-sonnet-4-20250514
|
||||
anthropic/claude-sonnet-4-5
|
||||
gitlab/duo-chat-haiku-4-5
|
||||
lmstudio/google/gemma-3n-e4b
|
||||
openai/gpt-4.1
|
||||
openai/gpt-5
|
||||
opencode/gpt-5.1-codex
|
||||
opencode/gpt-5.2-codex
|
||||
opencode/kimi-k2
|
||||
openrouter/google/gemini-2.5-flash
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: translate English to other languages
|
||||
model: opencode/claude-opus-4-7
|
||||
---
|
||||
|
||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Do not modify fenced code blocks.
|
||||
@@ -1,21 +1,30 @@
|
||||
---
|
||||
name: effect
|
||||
description: Answer questions about the Effect framework
|
||||
description: Work with Effect v4 / effect-smol TypeScript code in this repo
|
||||
---
|
||||
|
||||
# Effect
|
||||
|
||||
This codebase uses Effect, a framework for writing typescript.
|
||||
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
|
||||
|
||||
## How to Answer Effect Questions
|
||||
## Source Of Truth
|
||||
|
||||
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
|
||||
`.opencode/references/effect-smol` in this project NOT the skill folder.
|
||||
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
|
||||
3. Provide responses based on the actual Effect source code and documentation
|
||||
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
|
||||
|
||||
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
|
||||
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
||||
3. Also inspect existing repo code for local house style before introducing new patterns.
|
||||
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always use the explore agent with the cloned repository when answering Effect-related questions
|
||||
- Reference specific files and patterns found in the Effect codebase
|
||||
- Do not answer from memory - always verify against the source
|
||||
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
|
||||
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
|
||||
- Use `Effect.fn("Name")` or `Effect.fnUntraced(...)` for named effects when adding reusable service methods or important workflows.
|
||||
- Prefer Effect `Schema` for API and domain data shapes. Use branded schemas for IDs and `Schema.TaggedErrorClass` for typed domain errors when modeling new error surfaces.
|
||||
- Keep HTTP handlers thin: decode input, read request context, call services, and map transport errors. Put business rules in services.
|
||||
- In Effect service code, prefer Effect-aware platform abstractions and dependencies over ad hoc promises where the surrounding code already does so.
|
||||
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
|
||||
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
|
||||
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
|
||||
|
||||
@@ -3,8 +3,8 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||
tui: ["kommander", "rekram1-node", "simonklee"],
|
||||
core: ["kitlangton", "rekram1-node", "jlongster"],
|
||||
docs: ["R44VC0RP"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
@@ -199,22 +199,27 @@
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@npmcli/arborist": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"cross-spawn": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"semver": "catalog:",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
},
|
||||
@@ -382,15 +387,13 @@
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
@@ -472,7 +475,6 @@
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -668,7 +670,6 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@ai-sdk/anthropic@3.0.71": "patches/@ai-sdk%2Fanthropic@3.0.71.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -1584,7 +1585,7 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -5722,6 +5723,8 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="],
|
||||
|
||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
|
||||
|
||||
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"ansi-align/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=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0w22pAViYEcELJpOrIVCjTQ73fnsSaJxb75heAIUdYE=",
|
||||
"aarch64-linux": "sha256-z1fpZ9HQU9n6W5xhKzuUduwQUJa/nrj9WFZdBLL/e/8=",
|
||||
"aarch64-darwin": "sha256-y5AraTdY2uDTltjQFlHjMoMo6FICgQNKSunIOnQAXnY=",
|
||||
"x86_64-darwin": "sha256-KQF6dJNQ587xp5h9ET+tLni9dLNwYnzxg2DX+KWfpoE="
|
||||
"x86_64-linux": "sha256-I5qF+zaPXIXlPWjI54XlsZ42E5EgUWbo74/ClaoJnNY=",
|
||||
"aarch64-linux": "sha256-2yqFliYylXy3Lnf95nO/enefCrmP8GOfqKjnstdaMu4=",
|
||||
"aarch64-darwin": "sha256-/Rk2Q6bPxOzD24HPQOe/EXlcEb7eGLGHi3P3iSqHaTU=",
|
||||
"x86_64-darwin": "sha256-sKyoY9Z7858S0KG/mDhuGZLR2P7AdK+zy0xUVEJ3bt0="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
[
|
||||
ripgrep
|
||||
]
|
||||
# bun runs sysctl to detect if dunning on rosetta2
|
||||
# bun runs sysctl to detect if running on rosetta2
|
||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@ai-sdk/anthropic@3.0.71": "patches/@ai-sdk%2Fanthropic@3.0.71.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
|
||||
@@ -101,12 +101,13 @@ export async function handler(
|
||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
const userAgent = input.request.headers.get("user-agent") ?? ""
|
||||
logger.metric({
|
||||
is_stream: isStream,
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
|
||||
user_agent: userAgent,
|
||||
})
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
|
||||
@@ -18,23 +18,28 @@
|
||||
"imports": {},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/npmcli__arborist": "6.3.3"
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@npmcli/arborist": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"effect": "catalog:",
|
||||
"cross-spawn": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"semver": "catalog:",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
export * as Npm from "."
|
||||
export * as Npm from "./npm"
|
||||
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
import Config from "@npmcli/config"
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Global } from "./global"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
import { makeRuntime } from "./effect/runtime"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
|
||||
import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
@@ -45,7 +47,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
const npmPath = fileURLToPath(new URL("../..", import.meta.url))
|
||||
const npmPath = fileURLToPath(new URL("..", import.meta.url))
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
Vendored
-46
@@ -1,46 +0,0 @@
|
||||
declare module "@npmcli/arborist" {
|
||||
export interface ArboristOptions {
|
||||
path: string
|
||||
binLinks?: boolean
|
||||
progress?: boolean
|
||||
savePrefix?: string
|
||||
ignoreScripts?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ArboristEdge {
|
||||
to?: ArboristNode
|
||||
}
|
||||
|
||||
export interface ArboristTree {
|
||||
edgesOut: Map<string, ArboristEdge>
|
||||
}
|
||||
|
||||
export interface ReifyOptions {
|
||||
add?: string[]
|
||||
save?: boolean
|
||||
saveType?: "prod" | "dev" | "optional" | "peer"
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export class Arborist {
|
||||
constructor(options: ArboristOptions)
|
||||
loadVirtual(): Promise<ArboristTree | undefined>
|
||||
reify(options?: ReifyOptions): Promise<ArboristTree>
|
||||
}
|
||||
}
|
||||
|
||||
declare var Bun:
|
||||
| {
|
||||
file(path: string): {
|
||||
text(): Promise<string>
|
||||
json(): Promise<unknown>
|
||||
}
|
||||
write(path: string, content: string | Uint8Array): Promise<void>
|
||||
}
|
||||
| undefined
|
||||
+14
-4
@@ -1,11 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const live = CrossSpawnSpawner.defaultLayer
|
||||
@@ -39,11 +39,21 @@ function alive(pid: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tmpdir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-test-"))
|
||||
return {
|
||||
path: dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function gone(pid: number, timeout = 5_000) {
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
if (!alive(pid)) return true
|
||||
await Bun.sleep(50)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
return !alive(pid)
|
||||
}
|
||||
@@ -395,7 +405,7 @@ describe("cross-spawn spawner", () => {
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
|
||||
|
||||
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.exitCode(
|
||||
@@ -61,7 +61,6 @@
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -110,15 +109,13 @@
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
||||
@@ -30,6 +30,46 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
|
||||
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
|
||||
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
|
||||
|
||||
## Route Slice Checklist
|
||||
|
||||
Use this checklist for each small HttpApi migration PR:
|
||||
|
||||
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
|
||||
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
|
||||
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
|
||||
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
|
||||
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
|
||||
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
|
||||
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
|
||||
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
|
||||
|
||||
## Hono Deletion Checklist
|
||||
|
||||
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
|
||||
|
||||
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
|
||||
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
|
||||
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
|
||||
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
|
||||
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
|
||||
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
|
||||
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
|
||||
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
|
||||
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
|
||||
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
|
||||
|
||||
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
|
||||
|
||||
## Experimental Read Slice Guidance
|
||||
|
||||
For the experimental route group, port read-only JSON routes before mutations:
|
||||
|
||||
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
|
||||
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
|
||||
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
|
||||
- Preserve response headers such as pagination cursors if a route is ported.
|
||||
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
|
||||
|
||||
## Schema Notes
|
||||
|
||||
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
|
||||
@@ -70,7 +110,7 @@ Good near-term candidates:
|
||||
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
|
||||
- simple mutations: `POST /instance/dispose`
|
||||
- experimental JSON reads: console, tool, worktree list, resource list
|
||||
- deferred JSON mutations: `PATCH /config`, project git init, workspace/worktree create/remove/reset, file search, MCP auth flows
|
||||
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
|
||||
|
||||
Keep large or stateful groups for later:
|
||||
|
||||
@@ -130,29 +170,198 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
||||
|
||||
## Current Route Status
|
||||
|
||||
| Area | Status | Notes |
|
||||
| ------------------------ | ----------------- | -------------------------------------------------------------- |
|
||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||
| `permission` | `bridged` | list and reply |
|
||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||
| `config` | `bridged` partial | reads only; mutation remains Hono |
|
||||
| `project` | `bridged` partial | reads only; git-init remains Hono |
|
||||
| `file` | `bridged` partial | list/content/status only |
|
||||
| `mcp` | `bridged` partial | status only |
|
||||
| `workspace` | `bridged` | list, get, enter |
|
||||
| top-level instance reads | `bridged` partial | path and vcs reads; command, agent, skill, lsp, formatter next |
|
||||
| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list |
|
||||
| `session` | `later/special` | large stateful surface plus streaming |
|
||||
| `sync` | `later` | process/control side effects |
|
||||
| `event` | `special` | SSE |
|
||||
| `pty` | `special` | websocket |
|
||||
| `tui` | `special` | UI bridge |
|
||||
| Area | Status | Notes |
|
||||
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
|
||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||
| `permission` | `bridged` | list and reply |
|
||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||
| `config` | `bridged` | read, providers, update |
|
||||
| `project` | `bridged` | list, current, git init, update |
|
||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
|
||||
| `sync` | `bridged` | start/replay/history |
|
||||
| `event` | `special` | SSE |
|
||||
| `pty` | `special` | websocket |
|
||||
| `tui` | `special` | UI bridge |
|
||||
|
||||
## Next PRs
|
||||
## Full Route Checklist
|
||||
|
||||
1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
|
||||
2. Continue porting top-level JSON reads.
|
||||
3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
|
||||
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
|
||||
|
||||
### Top-Level Instance Routes
|
||||
|
||||
- [x] `POST /instance/dispose` - dispose active instance after response.
|
||||
- [x] `GET /path` - current directory and worktree paths.
|
||||
- [x] `GET /vcs` - current VCS status.
|
||||
- [x] `GET /vcs/diff` - VCS diff summary.
|
||||
- [x] `GET /command` - command catalog.
|
||||
- [x] `GET /agent` - agent catalog.
|
||||
- [x] `GET /skill` - skill catalog.
|
||||
- [x] `GET /lsp` - LSP status.
|
||||
- [x] `GET /formatter` - formatter status.
|
||||
|
||||
### Config Routes
|
||||
|
||||
- [x] `GET /config` - read config.
|
||||
- [x] `PATCH /config` - update config and dispose active instance after response.
|
||||
- [x] `GET /config/providers` - config provider summary.
|
||||
|
||||
### Project Routes
|
||||
|
||||
- [x] `GET /project` - list projects.
|
||||
- [x] `GET /project/current` - current project.
|
||||
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
|
||||
- [x] `PATCH /project/:projectID` - update project metadata.
|
||||
|
||||
### Provider Routes
|
||||
|
||||
- [x] `GET /provider` - list providers.
|
||||
- [x] `GET /provider/auth` - list provider auth methods.
|
||||
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
|
||||
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
|
||||
|
||||
### Question Routes
|
||||
|
||||
- [x] `GET /question` - list questions.
|
||||
- [x] `POST /question/:requestID/reply` - reply to question.
|
||||
- [x] `POST /question/:requestID/reject` - reject question.
|
||||
|
||||
### Permission Routes
|
||||
|
||||
- [x] `GET /permission` - list permission requests.
|
||||
- [x] `POST /permission/:requestID/reply` - reply to permission request.
|
||||
|
||||
### File Routes
|
||||
|
||||
- [x] `GET /find` - text search.
|
||||
- [x] `GET /find/file` - file search.
|
||||
- [x] `GET /find/symbol` - symbol search.
|
||||
- [x] `GET /file` - list directory entries.
|
||||
- [x] `GET /file/content` - read file content.
|
||||
- [x] `GET /file/status` - file status.
|
||||
|
||||
### MCP Routes
|
||||
|
||||
- [x] `GET /mcp` - MCP status.
|
||||
- [x] `POST /mcp` - add MCP server at runtime.
|
||||
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
|
||||
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
|
||||
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
|
||||
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
|
||||
- [x] `POST /mcp/:name/connect` - connect MCP server.
|
||||
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
|
||||
|
||||
### Experimental Routes
|
||||
|
||||
- [x] `GET /experimental/console` - active Console provider metadata.
|
||||
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
|
||||
- [x] `POST /experimental/console/switch` - switch active Console org.
|
||||
- [x] `GET /experimental/tool/ids` - tool IDs.
|
||||
- [x] `GET /experimental/tool` - tools for provider/model.
|
||||
- [x] `GET /experimental/worktree` - list worktrees.
|
||||
- [x] `POST /experimental/worktree` - create worktree.
|
||||
- [x] `DELETE /experimental/worktree` - remove worktree.
|
||||
- [x] `POST /experimental/worktree/reset` - reset worktree.
|
||||
- [x] `GET /experimental/session` - global session list.
|
||||
- [x] `GET /experimental/resource` - MCP resources.
|
||||
|
||||
### Workspace Routes
|
||||
|
||||
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
|
||||
- [x] `POST /experimental/workspace` - create workspace.
|
||||
- [x] `GET /experimental/workspace` - list workspaces.
|
||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
|
||||
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
|
||||
|
||||
### Sync Routes
|
||||
|
||||
- [x] `POST /sync/start` - start workspace sync.
|
||||
- [x] `POST /sync/replay` - replay sync events.
|
||||
- [x] `POST /sync/history` - list sync event history.
|
||||
|
||||
### Session Routes
|
||||
|
||||
- [x] `GET /session` - list sessions.
|
||||
- [x] `GET /session/status` - session status map.
|
||||
- [x] `GET /session/:sessionID` - get session.
|
||||
- [x] `GET /session/:sessionID/children` - get child sessions.
|
||||
- [x] `GET /session/:sessionID/todo` - get session todos.
|
||||
- [x] `POST /session` - create session.
|
||||
- [x] `DELETE /session/:sessionID` - delete session.
|
||||
- [x] `PATCH /session/:sessionID` - update session metadata.
|
||||
- [x] `POST /session/:sessionID/init` - run project init command.
|
||||
- [x] `POST /session/:sessionID/fork` - fork session.
|
||||
- [x] `POST /session/:sessionID/abort` - abort session.
|
||||
- [x] `POST /session/:sessionID/share` - share session.
|
||||
- [x] `GET /session/:sessionID/diff` - session diff.
|
||||
- [x] `DELETE /session/:sessionID/share` - unshare session.
|
||||
- [x] `POST /session/:sessionID/summarize` - summarize session.
|
||||
- [x] `GET /session/:sessionID/message` - list session messages.
|
||||
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
||||
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
|
||||
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
|
||||
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
|
||||
- [x] `POST /session/:sessionID/command` - run command.
|
||||
- [x] `POST /session/:sessionID/shell` - run shell command.
|
||||
- [x] `POST /session/:sessionID/revert` - revert message.
|
||||
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
|
||||
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
|
||||
|
||||
### Event Routes
|
||||
|
||||
- [ ] `GET /event` - SSE event stream; replace with raw Effect HTTP, not `HttpApi`.
|
||||
|
||||
### PTY Routes
|
||||
|
||||
- [ ] `GET /pty` - list PTY sessions.
|
||||
- [ ] `POST /pty` - create PTY session.
|
||||
- [ ] `GET /pty/:ptyID` - get PTY session.
|
||||
- [ ] `PUT /pty/:ptyID` - update PTY session.
|
||||
- [ ] `DELETE /pty/:ptyID` - remove PTY session.
|
||||
- [ ] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
|
||||
|
||||
### TUI Routes
|
||||
|
||||
- [ ] `POST /tui/append-prompt` - append prompt.
|
||||
- [ ] `POST /tui/open-help` - open help.
|
||||
- [ ] `POST /tui/open-sessions` - open sessions.
|
||||
- [ ] `POST /tui/open-themes` - open themes.
|
||||
- [ ] `POST /tui/open-models` - open models.
|
||||
- [ ] `POST /tui/submit-prompt` - submit prompt.
|
||||
- [ ] `POST /tui/clear-prompt` - clear prompt.
|
||||
- [ ] `POST /tui/execute-command` - execute command.
|
||||
- [ ] `POST /tui/show-toast` - show toast.
|
||||
- [ ] `POST /tui/publish` - publish TUI event.
|
||||
- [ ] `POST /tui/select-session` - select session.
|
||||
- [ ] `GET /tui/control/next` - get next TUI request.
|
||||
- [ ] `POST /tui/control/response` - submit TUI control response.
|
||||
|
||||
## Remaining PR Plan
|
||||
|
||||
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
|
||||
|
||||
1. [x] Bridge `PATCH /project/:projectID`.
|
||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||
4. [x] Bridge experimental console switch and tool list routes.
|
||||
5. [x] Bridge experimental global session list.
|
||||
6. [x] Bridge workspace create/remove/session-restore routes.
|
||||
7. [x] Bridge sync start/replay/history routes.
|
||||
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||
10. [x] Bridge remaining session mutation and prompt routes.
|
||||
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
||||
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
||||
13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
|
||||
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
|
||||
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -163,9 +372,9 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
||||
- [x] Attach auth middleware in route modules.
|
||||
- [x] Support `auth_token` as a query security scheme.
|
||||
- [x] Add bridge-level auth and instance tests.
|
||||
- [ ] Complete exact Hono route inventory.
|
||||
- [x] Complete exact Hono route inventory.
|
||||
- [x] Resolve implemented-but-unmounted route groups.
|
||||
- [ ] Port remaining JSON routes.
|
||||
- [x] Port remaining top-level JSON reads.
|
||||
- [ ] Generate SDK/OpenAPI from Effect routes.
|
||||
- [ ] Flip ported JSON routes to default-on with fallback.
|
||||
- [ ] Delete replaced Hono route implementations.
|
||||
|
||||
@@ -3,7 +3,6 @@ import z from "zod"
|
||||
import { Provider } from "../provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "../provider"
|
||||
@@ -19,37 +18,37 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, Context, Layer } from "effect"
|
||||
import { Effect, Context, Layer, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@/util/schema"
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
mode: z.enum(["subagent", "primary", "all"]),
|
||||
native: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
permission: Permission.Ruleset.zod,
|
||||
model: z
|
||||
.object({
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
})
|
||||
.optional(),
|
||||
variant: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
options: z.record(z.string(), z.any()),
|
||||
steps: z.number().int().positive().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Agent",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
native: Schema.optional(Schema.Boolean),
|
||||
hidden: Schema.optional(Schema.Boolean),
|
||||
topP: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
color: Schema.optional(Schema.String),
|
||||
permission: Permission.Ruleset,
|
||||
model: Schema.optional(
|
||||
Schema.Struct({
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
}),
|
||||
),
|
||||
variant: Schema.optional(Schema.String),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||
steps: Schema.optional(Schema.Number),
|
||||
})
|
||||
.annotate({ identifier: "Agent" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (agent: string) => Effect.Effect<Info>
|
||||
@@ -79,7 +78,7 @@ export const layer = Layer.effect(
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (_ctx) {
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
@@ -136,7 +135,7 @@ export const layer = Layer.effect(
|
||||
edit: {
|
||||
"*": "deny",
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem, Log } from "@/util"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "@/util"
|
||||
import type { EditorSelection } from "./editor"
|
||||
|
||||
const ZedEditorRowSchema = z.object({
|
||||
editor_id: z.number(),
|
||||
workspace_id: z.number(),
|
||||
workspace_paths: z.string().nullable(),
|
||||
timestamp: z.string(),
|
||||
buffer_path: z.string().nullable(),
|
||||
selection_start: z.number().nullable(),
|
||||
selection_end: z.number().nullable(),
|
||||
})
|
||||
|
||||
const ZedEditorContentsSchema = z.object({
|
||||
contents: z.string().nullable(),
|
||||
})
|
||||
|
||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||
|
||||
export async function resolveZedSelection(dbPath: string): Promise<EditorSelection | undefined> {
|
||||
const row = queryZedActiveEditor(dbPath, process.cwd())
|
||||
if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return
|
||||
|
||||
const text =
|
||||
queryZedEditorContents(dbPath, row) ??
|
||||
(await Bun.file(row.buffer_path)
|
||||
.text()
|
||||
.catch(() => undefined))
|
||||
if (text == null) return
|
||||
|
||||
const startOffset = Math.min(row.selection_start, row.selection_end)
|
||||
const endOffset = Math.max(row.selection_start, row.selection_end)
|
||||
|
||||
return {
|
||||
text: text.slice(startOffset, endOffset),
|
||||
filePath: row.buffer_path,
|
||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
return db
|
||||
.query(
|
||||
`select
|
||||
e.item_id as editor_id,
|
||||
e.workspace_id as workspace_id,
|
||||
w.paths as workspace_paths,
|
||||
w.timestamp as timestamp,
|
||||
e.buffer_path as buffer_path,
|
||||
s.start as selection_start,
|
||||
s.end as selection_end
|
||||
from items i
|
||||
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
|
||||
join workspaces w on w.workspace_id = i.workspace_id
|
||||
join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
||||
left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
|
||||
where i.active = 1 and p.active = 1 and i.kind = 'Editor' and e.buffer_path is not null
|
||||
order by w.timestamp desc`,
|
||||
)
|
||||
.all()
|
||||
.flatMap((row) => {
|
||||
const parsed = ZedEditorRowSchema.safeParse(row)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
return ZedEditorContentsSchema.safeParse(
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
from editors
|
||||
where item_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
).data?.contents
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveZedDbPath() {
|
||||
const candidates = [
|
||||
process.env.OPENCODE_ZED_DB,
|
||||
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
|
||||
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return candidates.find((item) => Filesystem.stat(item)?.isFile())
|
||||
}
|
||||
|
||||
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
||||
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
||||
if (pathContains(item, cwd)) return Math.max(score, 2)
|
||||
if (pathContains(cwd, item)) return Math.max(score, 1)
|
||||
return score
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function zedWorkspacePaths(value: string | null) {
|
||||
if (!value) return []
|
||||
const parsed = parseJson(value)
|
||||
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
|
||||
return value.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
return offsetsToSelection(text, offset, offset).start
|
||||
}
|
||||
|
||||
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
||||
const start = Math.max(0, Math.min(startOffset, text.length))
|
||||
const end = Math.max(0, Math.min(endOffset, text.length))
|
||||
let line = 1
|
||||
let lineStart = 0
|
||||
let startPosition = position(line, lineStart, start)
|
||||
let endPosition = position(line, lineStart, end)
|
||||
|
||||
for (let index = 0; index <= end; index++) {
|
||||
if (index === start) startPosition = position(line, lineStart, index)
|
||||
if (index === end) {
|
||||
endPosition = position(line, lineStart, index)
|
||||
break
|
||||
}
|
||||
if (text[index] === "\n") {
|
||||
line += 1
|
||||
lineStart = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
return { start: startPosition, end: endPosition }
|
||||
}
|
||||
|
||||
function position(line: number, lineStart: number, offset: number) {
|
||||
return {
|
||||
line,
|
||||
character: offset - lineStart + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function pathContains(parent: string, child: string) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import path from "node:path"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import z from "zod"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
|
||||
@@ -90,6 +92,8 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
let reconnect: ReturnType<typeof setTimeout> | undefined
|
||||
let attempt = 0
|
||||
let requestID = 0
|
||||
let zedSelection: Promise<void> | undefined
|
||||
let lastZedSelectionKey: string | undefined
|
||||
const pending = new Map<number, string>()
|
||||
|
||||
const send = (payload: JsonRpcMessage) => {
|
||||
@@ -114,7 +118,29 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
|
||||
const connection = resolveEditorConnection()
|
||||
if (!connection) {
|
||||
setStore("status", "disabled")
|
||||
const dbPath = resolveZedDbPath()
|
||||
if (!dbPath) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect(1000)
|
||||
return
|
||||
}
|
||||
zedSelection ??= resolveZedSelection(dbPath)
|
||||
.then((selection) => {
|
||||
if (closed || socket) return
|
||||
const key = editorSelectionKey(selection)
|
||||
if (key !== lastZedSelectionKey) {
|
||||
lastZedSelectionKey = key
|
||||
setStore("selection", selection)
|
||||
setStore("status", selection ? "connected" : "disabled")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (closed || socket) return
|
||||
setStore("status", "disabled")
|
||||
})
|
||||
.finally(() => {
|
||||
zedSelection = undefined
|
||||
})
|
||||
scheduleReconnect(1000)
|
||||
return
|
||||
}
|
||||
@@ -196,7 +222,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
|
||||
return {
|
||||
enabled() {
|
||||
return Boolean(resolveEditorConnection())
|
||||
return Boolean(resolveEditorConnection() || resolveZedDbPath())
|
||||
},
|
||||
connected() {
|
||||
return store.status === "connected"
|
||||
@@ -289,6 +315,18 @@ function scoreEditorLock(lock: EditorLockFile, cwd: string) {
|
||||
return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
|
||||
}
|
||||
|
||||
function editorSelectionKey(selection: EditorSelection | undefined) {
|
||||
if (!selection) return ""
|
||||
return [
|
||||
selection.filePath,
|
||||
selection.selection.start.line,
|
||||
selection.selection.start.character,
|
||||
selection.selection.end.line,
|
||||
selection.selection.end.character,
|
||||
selection.text,
|
||||
].join("\0")
|
||||
}
|
||||
|
||||
function pathContains(parent: string, child: string) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
@@ -313,7 +351,3 @@ function parseMessage(value: unknown) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For } from "solid-js"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
@@ -30,9 +30,12 @@ function parse(tip: string): TipPart[] {
|
||||
return parts
|
||||
}
|
||||
|
||||
export function Tips() {
|
||||
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
|
||||
|
||||
export function Tips(props: { connected?: boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
|
||||
const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)]
|
||||
const parts = createMemo(() => parse(props.connected === false ? NO_MODELS_TIP : randomTip))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" maxWidth="100%">
|
||||
@@ -40,7 +43,7 @@ export function Tips() {
|
||||
● Tip{" "}
|
||||
</text>
|
||||
<text flexShrink={1}>
|
||||
<For each={parts}>
|
||||
<For each={parts()}>
|
||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||
</For>
|
||||
</text>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Tips } from "./tips-view"
|
||||
|
||||
const id = "internal:home-tips"
|
||||
|
||||
function View(props: { show: boolean }) {
|
||||
function View(props: { show: boolean; connected: boolean }) {
|
||||
return (
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={props.show}>
|
||||
<Tips />
|
||||
<Tips connected={props.connected} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
@@ -35,8 +35,13 @@ const tui: TuiPlugin = async (api) => {
|
||||
home_bottom() {
|
||||
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
||||
const first = createMemo(() => api.state.session.count() === 0)
|
||||
const show = createMemo(() => !first() && !hidden())
|
||||
return <View show={show()} />
|
||||
const connected = createMemo(() =>
|
||||
api.state.provider.some(
|
||||
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
|
||||
),
|
||||
)
|
||||
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
||||
return <View show={show()} connected={connected()} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Layer } from "effect"
|
||||
import { TuiConfig } from "./config/tui"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Observability } from "@opencode-ai/core/effect/observability"
|
||||
|
||||
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Config } from "../config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
@@ -27,25 +29,22 @@ export const Event = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
source: z.enum(["command", "mcp", "skill"]).optional(),
|
||||
// workaround for zod not supporting async functions natively so we use getters
|
||||
// https://zod.dev/v4/changelog?id=zfunction
|
||||
template: z.promise(z.string()).or(z.string()),
|
||||
subtask: z.boolean().optional(),
|
||||
hints: z.array(z.string()),
|
||||
})
|
||||
.meta({
|
||||
ref: "Command",
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||
// Some command templates are lazy promises from MCP prompt resolution.
|
||||
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
})
|
||||
.annotate({ identifier: "Command" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
|
||||
export function hints(template: string) {
|
||||
const result: string[] = []
|
||||
|
||||
@@ -42,7 +42,7 @@ import { ConfigProvider } from "./provider"
|
||||
import { ConfigServer } from "./server"
|
||||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -280,7 +280,7 @@ export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||
readonly update: (config: Info) => Effect.Effect<void>
|
||||
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||
readonly directories: () => Effect.Effect<string[]>
|
||||
@@ -719,14 +719,14 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs
|
||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share"
|
||||
import { SessionShare } from "@/share"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Log } from "@/util"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Npm } from "../npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import { Filesystem } from "../util"
|
||||
import { Process } from "../util"
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Config } from "../config"
|
||||
import { Log } from "../util"
|
||||
import * as Formatter from "./formatter"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
extensions: z.string().array(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FormatterStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
export const Status = Schema.Struct({
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
enabled: Schema.Boolean,
|
||||
})
|
||||
.annotate({ identifier: "FormatterStatus" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Process } from "../util"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/core/util/module"
|
||||
import { spawn } from "./launch"
|
||||
import { Npm } from "../npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
const pathExists = async (p: string) =>
|
||||
|
||||
@@ -29,23 +29,23 @@ import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export const Resource = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
uri: z.string(),
|
||||
description: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
client: z.string(),
|
||||
})
|
||||
.meta({ ref: "McpResource" })
|
||||
export type Resource = z.infer<typeof Resource>
|
||||
export const Resource = Schema.Struct({
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
mimeType: Schema.optional(Schema.String),
|
||||
client: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "McpResource" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type Resource = Schema.Schema.Type<typeof Resource>
|
||||
|
||||
export const ToolsChanged = BusEvent.define(
|
||||
"mcp.tools.changed",
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
declare module "@npmcli/config" {
|
||||
type Data = Record<string, unknown>
|
||||
type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli"
|
||||
|
||||
namespace Config {
|
||||
interface Options {
|
||||
definitions: Data
|
||||
shorthands: Record<string, string | string[]>
|
||||
npmPath: string
|
||||
flatten?: (input: Data, flat?: Data) => Data
|
||||
nerfDarts?: string[]
|
||||
argv?: string[]
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
execPath?: string
|
||||
platform?: NodeJS.Platform
|
||||
warn?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
class Config {
|
||||
constructor(input: Config.Options)
|
||||
|
||||
readonly data: Map<Where, { source: string | null }>
|
||||
readonly flat: Data
|
||||
|
||||
load(): Promise<void>
|
||||
}
|
||||
|
||||
export = Config
|
||||
}
|
||||
|
||||
declare module "@npmcli/config/lib/definitions" {
|
||||
export const definitions: Record<string, unknown>
|
||||
export const shorthands: Record<string, string | string[]>
|
||||
export const flatten: (input: Record<string, unknown>, flat?: Record<string, unknown>) => Record<string, unknown>
|
||||
export const nerfDarts: string[]
|
||||
export const proxyEnv: string[]
|
||||
}
|
||||
|
||||
declare module "@npmcli/config/lib/definitions/index.js" {
|
||||
export * from "@npmcli/config/lib/definitions"
|
||||
}
|
||||
@@ -22,13 +22,14 @@ export const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Action = Schema.Schema.Type<typeof Action>
|
||||
|
||||
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||
export const Rule = Schema.Struct({
|
||||
permission: Schema.String,
|
||||
pattern: Schema.String,
|
||||
action: Action,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
})
|
||||
.annotate({ identifier: "PermissionRule" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
||||
.annotate({ identifier: "PermissionRuleset" })
|
||||
|
||||
@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
import { Filesystem } from "@/util"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
// Old npm package names for plugins that are now built-in
|
||||
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effe
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
@@ -91,6 +91,15 @@ export const UpdateInput = z.object({
|
||||
})
|
||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||
|
||||
export const UpdatePayload = Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
icon: Schema.optional(ProjectIcon),
|
||||
commands: Schema.optional(ProjectCommands),
|
||||
})
|
||||
.annotate({ identifier: "ProjectUpdateInput" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Config } from "../config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util"
|
||||
import { Npm } from "../npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
||||
|
||||
@@ -193,7 +193,11 @@ function normalizeMessages(
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
|
||||
if (
|
||||
typeof model.capabilities.interleaved === "object" &&
|
||||
model.capabilities.interleaved.field &&
|
||||
model.api.npm !== "@openrouter/ai-sdk-provider"
|
||||
) {
|
||||
const field = model.capabilities.interleaved.field
|
||||
return msgs.map((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
@@ -418,8 +422,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
const id = model.id.toLowerCase()
|
||||
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
|
||||
if (
|
||||
id.includes("deepseek-chat") ||
|
||||
id.includes("deepseek-reasoner") ||
|
||||
id.includes("deepseek-r1") ||
|
||||
id.includes("deepseek-v3") ||
|
||||
id.includes("minimax") ||
|
||||
@@ -612,27 +614,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
}
|
||||
}
|
||||
|
||||
if (model.api.id.includes("deepseek")) {
|
||||
return {
|
||||
high: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
},
|
||||
output_config: {
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
max: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
},
|
||||
output_config: {
|
||||
effort: "max",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (adaptiveEfforts) {
|
||||
return Object.fromEntries(
|
||||
adaptiveEfforts.map((effort) => [
|
||||
|
||||
@@ -230,14 +230,14 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
description: "Worktree created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Worktree.Info),
|
||||
schema: resolver(Worktree.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
validator("json", Worktree.CreateInput.zod.optional()),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
@@ -286,7 +286,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
validator("json", Worktree.RemoveInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
@@ -315,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput),
|
||||
validator("json", Worktree.ResetInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
@@ -394,7 +394,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
description: "MCP resources",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Resource)),
|
||||
schema: resolver(z.record(z.string(), MCP.Resource.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Config } from "@/config"
|
||||
import { Provider } from "@/provider"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForDisposal } from "./lifecycle"
|
||||
|
||||
const root = "/config"
|
||||
|
||||
@@ -19,6 +21,16 @@ export const ConfigApi = HttpApi.make("config")
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", root, {
|
||||
payload: Config.Info,
|
||||
success: Config.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "config.update",
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||
success: Provider.ConfigProvidersResult,
|
||||
}).annotateMerge(
|
||||
@@ -54,6 +66,13 @@ export const configHandlers = Layer.unwrap(
|
||||
return yield* configSvc.get()
|
||||
})
|
||||
|
||||
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
|
||||
const payload = Config.Info.zod.parse(ctx.payload)
|
||||
yield* configSvc.update(payload, { dispose: false })
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return payload
|
||||
})
|
||||
|
||||
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
|
||||
const providers = yield* providerSvc.list()
|
||||
return {
|
||||
@@ -63,7 +82,7 @@ export const configHandlers = Layer.unwrap(
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
|
||||
handlers.handle("get", get).handle("providers", providers),
|
||||
handlers.handle("get", get).handle("update", update).handle("providers", providers),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Config } from "@/config"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session"
|
||||
import { ToolRegistry } from "@/tool"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optionalKey(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
}).annotate({ identifier: "ConsoleState" })
|
||||
|
||||
const ConsoleOrgOption = Schema.Struct({
|
||||
accountID: Schema.String,
|
||||
accountEmail: Schema.String,
|
||||
accountUrl: Schema.String,
|
||||
orgID: Schema.String,
|
||||
orgName: Schema.String,
|
||||
active: Schema.Boolean,
|
||||
}).annotate({ identifier: "ConsoleOrgOption" })
|
||||
|
||||
const ConsoleOrgList = Schema.Struct({
|
||||
orgs: Schema.Array(ConsoleOrgOption),
|
||||
}).annotate({ identifier: "ConsoleOrgList" })
|
||||
|
||||
const ConsoleSwitchPayload = Schema.Struct({
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
}).annotate({ identifier: "ConsoleSwitchInput" })
|
||||
|
||||
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
|
||||
const ToolListItem = Schema.Struct({
|
||||
id: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: Schema.Record(Schema.String, Schema.Any),
|
||||
}).annotate({ identifier: "ToolListItem" })
|
||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||
const ToolListQuery = Schema.Struct({
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
})
|
||||
|
||||
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
||||
const SessionListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
cursor: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
archived: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
})
|
||||
|
||||
export const ExperimentalPaths = {
|
||||
console: "/experimental/console",
|
||||
consoleOrgs: "/experimental/console/orgs",
|
||||
consoleSwitch: "/experimental/console/switch",
|
||||
tool: "/experimental/tool",
|
||||
toolIDs: "/experimental/tool/ids",
|
||||
worktree: "/experimental/worktree",
|
||||
worktreeReset: "/experimental/worktree/reset",
|
||||
session: "/experimental/session",
|
||||
resource: "/experimental/resource",
|
||||
} as const
|
||||
|
||||
export const ExperimentalApi = HttpApi.make("experimental")
|
||||
.add(
|
||||
HttpApiGroup.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
success: ConsoleStateResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.get",
|
||||
summary: "Get active Console provider metadata",
|
||||
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
success: ConsoleOrgList,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.listOrgs",
|
||||
summary: "List switchable Console orgs",
|
||||
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||
payload: ConsoleSwitchPayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.switchOrg",
|
||||
summary: "Switch active Console org",
|
||||
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
|
||||
query: ToolListQuery,
|
||||
success: ToolList,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tool.list",
|
||||
summary: "List tools",
|
||||
description:
|
||||
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||
success: ToolIDs,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tool.ids",
|
||||
summary: "List tool IDs",
|
||||
description:
|
||||
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||
success: WorktreeList,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.list",
|
||||
summary: "List worktrees",
|
||||
description: "List all sandbox worktrees for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
payload: Schema.optional(Worktree.CreateInput),
|
||||
success: Worktree.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a new git worktree for the current project and run any configured startup scripts.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||
payload: Worktree.RemoveInput,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.remove",
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a git worktree and delete its branch.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||
payload: Worktree.ResetInput,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.reset",
|
||||
summary: "Reset worktree",
|
||||
description: "Reset a worktree branch to the primary default branch.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
|
||||
query: SessionListQuery,
|
||||
success: Schema.Array(Session.GlobalInfo),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.session.list",
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||
success: Schema.Record(Schema.String, MCP.Resource),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.resource.list",
|
||||
summary: "Get MCP resources",
|
||||
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "experimental",
|
||||
description: "Experimental HttpApi read-only routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const experimentalHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const project = yield* Project.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const worktreeSvc = yield* Worktree.Service
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
return {
|
||||
consoleManagedProviders: state.consoleManagedProviders,
|
||||
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
})
|
||||
|
||||
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
||||
const [groups, active] = yield* Effect.all(
|
||||
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const info = Option.getOrUndefined(active)
|
||||
return {
|
||||
orgs: groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
|
||||
payload: typeof ConsoleSwitchPayload.Type
|
||||
}) {
|
||||
yield* account
|
||||
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
|
||||
const list = yield* registry.tools({
|
||||
providerID: ctx.query.provider,
|
||||
modelID: ctx.query.model,
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
}))
|
||||
})
|
||||
|
||||
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
|
||||
return yield* registry.ids()
|
||||
})
|
||||
|
||||
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return yield* project.sandboxes(ctx.project.id)
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
|
||||
payload: Worktree.CreateInput | undefined
|
||||
}) {
|
||||
return yield* worktreeSvc.create(ctx.payload)
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
|
||||
payload: Worktree.RemoveInput
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* worktreeSvc.remove(input.payload)
|
||||
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
|
||||
return true
|
||||
})
|
||||
|
||||
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
|
||||
payload: Worktree.ResetInput
|
||||
}) {
|
||||
yield* worktreeSvc.reset(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||
const limit = ctx.query.limit ?? 100
|
||||
const sessions = Array.from(
|
||||
Session.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots === "true" ? true : undefined,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
search: ctx.query.search,
|
||||
limit: limit + 1,
|
||||
archived: ctx.query.archived === "true" ? true : undefined,
|
||||
}),
|
||||
)
|
||||
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
|
||||
return HttpServerResponse.jsonUnsafe(list, {
|
||||
headers:
|
||||
sessions.length > limit && list.length > 0
|
||||
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||
return yield* mcp.resources()
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
|
||||
handlers
|
||||
.handle("console", getConsole)
|
||||
.handle("consoleOrgs", listConsoleOrgs)
|
||||
.handle("consoleSwitch", switchConsole)
|
||||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
.handle("session", session)
|
||||
.handle("resource", resource),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Worktree.defaultLayer),
|
||||
)
|
||||
@@ -1,4 +1,7 @@
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { LSP } from "@/lsp"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
@@ -7,7 +10,27 @@ const FileQuery = Schema.Struct({
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
const FindTextQuery = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
})
|
||||
|
||||
const FindFileQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
),
|
||||
})
|
||||
|
||||
const FindSymbolQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
})
|
||||
|
||||
export const FilePaths = {
|
||||
findText: "/find",
|
||||
findFile: "/find/file",
|
||||
findSymbol: "/find/symbol",
|
||||
list: "/file",
|
||||
content: "/file/content",
|
||||
status: "/file/status",
|
||||
@@ -17,6 +40,36 @@ export const FileApi = HttpApi.make("file")
|
||||
.add(
|
||||
HttpApiGroup.make("file")
|
||||
.add(
|
||||
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
||||
query: FindTextQuery,
|
||||
success: Schema.Array(Ripgrep.SearchMatch),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.text",
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
|
||||
query: FindFileQuery,
|
||||
success: Schema.Array(Schema.String),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.files",
|
||||
summary: "Find files",
|
||||
description: "Search for files or directories by name or pattern in the project directory.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
|
||||
query: FindSymbolQuery,
|
||||
success: Schema.Array(LSP.Symbol),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.symbols",
|
||||
summary: "Find symbols",
|
||||
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", FilePaths.list, {
|
||||
query: FileQuery,
|
||||
success: Schema.Array(File.Node),
|
||||
@@ -66,6 +119,28 @@ export const FileApi = HttpApi.make("file")
|
||||
export const fileHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* File.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||
return (yield* ripgrep
|
||||
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
|
||||
.pipe(Effect.orDie)).items
|
||||
})
|
||||
|
||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||
}) {
|
||||
return yield* svc.search({
|
||||
query: ctx.query.query,
|
||||
limit: ctx.query.limit ?? 10,
|
||||
dirs: ctx.query.dirs !== "false",
|
||||
type: ctx.query.type,
|
||||
})
|
||||
})
|
||||
|
||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||
return []
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.list(ctx.query.path)
|
||||
@@ -80,7 +155,13 @@ export const fileHandlers = Layer.unwrap(
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
|
||||
handlers.handle("list", list).handle("content", content).handle("status", status),
|
||||
handlers
|
||||
.handle("findText", findText)
|
||||
.handle("findFile", findFile)
|
||||
.handle("findSymbol", findSymbol)
|
||||
.handle("list", list)
|
||||
.handle("content", content)
|
||||
.handle("status", status),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(File.defaultLayer))
|
||||
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Format } from "@/format"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { LSP } from "@/lsp"
|
||||
import { Vcs } from "@/project"
|
||||
import { Skill } from "@/skill"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForDisposal } from "./lifecycle"
|
||||
|
||||
const PathInfo = Schema.Struct({
|
||||
home: Schema.String,
|
||||
@@ -18,15 +24,30 @@ const VcsDiffQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const InstancePaths = {
|
||||
dispose: "/instance/dispose",
|
||||
path: "/path",
|
||||
vcs: "/vcs",
|
||||
vcsDiff: "/vcs/diff",
|
||||
command: "/command",
|
||||
agent: "/agent",
|
||||
skill: "/skill",
|
||||
lsp: "/lsp",
|
||||
formatter: "/formatter",
|
||||
} as const
|
||||
|
||||
export const InstanceApi = HttpApi.make("instance")
|
||||
.add(
|
||||
HttpApiGroup.make("instance")
|
||||
.add(
|
||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "instance.dispose",
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("path", InstancePaths.path, {
|
||||
success: PathInfo,
|
||||
}).annotateMerge(
|
||||
@@ -57,6 +78,51 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||
success: Schema.Array(Command.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "command.list",
|
||||
summary: "List commands",
|
||||
description: "Get a list of all available commands in the OpenCode system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||
success: Schema.Array(Agent.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "app.agents",
|
||||
summary: "List agents",
|
||||
description: "Get a list of all available AI agents in the OpenCode system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||
success: Schema.Array(Skill.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "app.skills",
|
||||
summary: "List skills",
|
||||
description: "Get a list of all available skills in the OpenCode system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||
success: Schema.Array(LSP.Status),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "lsp.status",
|
||||
summary: "Get LSP status",
|
||||
description: "Get LSP server status",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||
success: Schema.Array(Format.Status),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "formatter.status",
|
||||
summary: "Get formatter status",
|
||||
description: "Get formatter status",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -76,8 +142,18 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
|
||||
export const instanceHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const command = yield* Command.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const skill = yield* Skill.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
|
||||
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return true
|
||||
})
|
||||
|
||||
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return {
|
||||
@@ -98,8 +174,44 @@ export const instanceHandlers = Layer.unwrap(
|
||||
return yield* vcs.diff(ctx.query.mode)
|
||||
})
|
||||
|
||||
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
||||
return yield* command.list()
|
||||
})
|
||||
|
||||
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
|
||||
return yield* agent.list()
|
||||
})
|
||||
|
||||
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
|
||||
return yield* skill.all()
|
||||
})
|
||||
|
||||
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
|
||||
return yield* lsp.status()
|
||||
})
|
||||
|
||||
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
|
||||
return yield* format.status()
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
|
||||
handlers.handle("path", getPath).handle("vcs", getVcs).handle("vcsDiff", getVcsDiff),
|
||||
handlers
|
||||
.handle("dispose", dispose)
|
||||
.handle("path", getPath)
|
||||
.handle("vcs", getVcs)
|
||||
.handle("vcsDiff", getVcsDiff)
|
||||
.handle("command", getCommand)
|
||||
.handle("agent", getAgent)
|
||||
.handle("skill", getSkill)
|
||||
.handle("lsp", getLsp)
|
||||
.handle("formatter", getFormatter),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(Vcs.defaultLayer))
|
||||
).pipe(
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Effect } from "effect"
|
||||
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
|
||||
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
|
||||
|
||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
Effect.sync(() => {
|
||||
disposeAfterResponse.set(request.source, ctx)
|
||||
return response
|
||||
}),
|
||||
)
|
||||
|
||||
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
Effect.sync(() => {
|
||||
reloadAfterResponse.set(request.source, { ...ctx, next })
|
||||
return response
|
||||
}),
|
||||
)
|
||||
|
||||
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* effect
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const reload = reloadAfterResponse.get(request.source)
|
||||
if (reload) {
|
||||
reloadAfterResponse.delete(request.source)
|
||||
yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
|
||||
return response
|
||||
}
|
||||
|
||||
const ctx = disposeAfterResponse.get(request.source)
|
||||
if (!ctx) return response
|
||||
disposeAfterResponse.delete(request.source)
|
||||
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
|
||||
return response
|
||||
})
|
||||
@@ -1,10 +1,33 @@
|
||||
import { MCP } from "@/mcp"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const AddPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
config: ConfigMCP.Info,
|
||||
}).annotate({ identifier: "McpAddInput" })
|
||||
|
||||
const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||
const AuthStartResponse = Schema.Struct({
|
||||
authorizationUrl: Schema.String,
|
||||
oauthState: Schema.String,
|
||||
}).annotate({ identifier: "McpAuthStartResponse" })
|
||||
const AuthCallbackPayload = Schema.Struct({
|
||||
code: Schema.String,
|
||||
}).annotate({ identifier: "McpAuthCallbackInput" })
|
||||
const AuthRemoveResponse = Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
}).annotate({ identifier: "McpAuthRemoveResponse" })
|
||||
|
||||
export const McpPaths = {
|
||||
status: "/mcp",
|
||||
auth: "/mcp/:name/auth",
|
||||
authCallback: "/mcp/:name/auth/callback",
|
||||
authAuthenticate: "/mcp/:name/auth/authenticate",
|
||||
connect: "/mcp/:name/connect",
|
||||
disconnect: "/mcp/:name/disconnect",
|
||||
} as const
|
||||
|
||||
export const McpApi = HttpApi.make("mcp")
|
||||
@@ -20,6 +43,76 @@ export const McpApi = HttpApi.make("mcp")
|
||||
description: "Get the status of all Model Context Protocol (MCP) servers.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||
payload: AddPayload,
|
||||
success: StatusMap,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.add",
|
||||
summary: "Add MCP server",
|
||||
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
success: AuthStartResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.start",
|
||||
summary: "Start MCP OAuth",
|
||||
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||
params: { name: Schema.String },
|
||||
payload: AuthCallbackPayload,
|
||||
success: MCP.Status,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.callback",
|
||||
summary: "Complete MCP OAuth",
|
||||
description:
|
||||
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||
params: { name: Schema.String },
|
||||
success: MCP.Status,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.authenticate",
|
||||
summary: "Authenticate MCP OAuth",
|
||||
description: "Start OAuth flow and wait for callback (opens browser).",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
success: AuthRemoveResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.remove",
|
||||
summary: "Remove MCP OAuth",
|
||||
description: "Remove OAuth credentials for an MCP server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||
params: { name: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.connect",
|
||||
description: "Connect an MCP server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||
params: { name: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.disconnect",
|
||||
description: "Disconnect an MCP server.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -45,6 +138,55 @@ export const mcpHandlers = Layer.unwrap(
|
||||
return yield* mcp.status()
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(McpApi, "mcp", (handlers) => handlers.handle("status", status))
|
||||
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
|
||||
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
|
||||
return yield* Schema.decodeUnknownEffect(StatusMap)(
|
||||
"status" in result ? { [ctx.payload.name]: result } : result,
|
||||
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
})
|
||||
|
||||
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
|
||||
return yield* mcp.startAuth(ctx.params.name)
|
||||
})
|
||||
|
||||
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||
params: { name: string }
|
||||
payload: typeof AuthCallbackPayload.Type
|
||||
}) {
|
||||
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
})
|
||||
|
||||
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
|
||||
return yield* mcp.authenticate(ctx.params.name)
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.removeAuth(ctx.params.name)
|
||||
return { success: true as const }
|
||||
})
|
||||
|
||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.connect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.disconnect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
|
||||
handlers
|
||||
.handle("status", status)
|
||||
.handle("add", add)
|
||||
.handle("authStart", authStart)
|
||||
.handle("authCallback", authCallback)
|
||||
.handle("authAuthenticate", authAuthenticate)
|
||||
.handle("authRemove", authRemove)
|
||||
.handle("connect", connect)
|
||||
.handle("disconnect", disconnect),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(MCP.defaultLayer))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Project } from "@/project"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForReload } from "./lifecycle"
|
||||
|
||||
const root = "/project"
|
||||
|
||||
@@ -28,6 +32,26 @@ export const ProjectApi = HttpApi.make("project")
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||
success: Project.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.initGit",
|
||||
summary: "Initialize git repository",
|
||||
description: "Create a git repository for the current project and return the refreshed project info.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||
params: { projectID: ProjectID },
|
||||
payload: Project.UpdatePayload,
|
||||
success: Project.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.update",
|
||||
summary: "Update project",
|
||||
description: "Update project properties such as name, icon, and commands.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -57,8 +81,29 @@ export const projectHandlers = Layer.unwrap(
|
||||
return (yield* InstanceState.context).project
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
|
||||
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
|
||||
return next
|
||||
yield* markInstanceForReload(ctx, {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.directory,
|
||||
project: next,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
|
||||
params: { projectID: ProjectID }
|
||||
payload: Project.UpdatePayload
|
||||
}) {
|
||||
return yield* svc.update({ ...Project.UpdatePayload.zod.parse(ctx.payload), projectID: ctx.params.projectID })
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
|
||||
handlers.handle("list", list).handle("current", current),
|
||||
handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(Project.defaultLayer))
|
||||
|
||||
@@ -11,13 +11,17 @@ import { Filesystem } from "@/util"
|
||||
import { authorizationLayer } from "./auth"
|
||||
import { ConfigApi, configHandlers } from "./config"
|
||||
import { FileApi, fileHandlers } from "./file"
|
||||
import { ExperimentalApi, experimentalHandlers } from "./experimental"
|
||||
import { InstanceApi, instanceHandlers } from "./instance"
|
||||
import { McpApi, mcpHandlers } from "./mcp"
|
||||
import { PermissionApi, permissionHandlers } from "./permission"
|
||||
import { ProjectApi, projectHandlers } from "./project"
|
||||
import { ProviderApi, providerHandlers } from "./provider"
|
||||
import { QuestionApi, questionHandlers } from "./question"
|
||||
import { SessionApi, sessionHandlers } from "./session"
|
||||
import { SyncApi, syncHandlers } from "./sync"
|
||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
const Query = Schema.Struct({
|
||||
@@ -63,6 +67,7 @@ const instance = HttpRouter.middleware()(
|
||||
|
||||
export const routes = Layer.mergeAll(
|
||||
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
|
||||
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
|
||||
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
|
||||
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
|
||||
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
|
||||
@@ -70,6 +75,8 @@ export const routes = Layer.mergeAll(
|
||||
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
||||
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
||||
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
||||
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
||||
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
||||
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||
).pipe(
|
||||
Layer.provide(authorizationLayer),
|
||||
@@ -81,6 +88,7 @@ export const routes = Layer.mergeAll(
|
||||
export const webHandler = lazy(() =>
|
||||
HttpRouter.toWebHandler(routes, {
|
||||
memoMap,
|
||||
middleware: disposeMiddleware,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,924 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionShare } from "@/share"
|
||||
import { Session } from "@/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Effect, Layer, Schema, Struct } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiBuilder,
|
||||
HttpApiEndpoint,
|
||||
HttpApiError,
|
||||
HttpApiGroup,
|
||||
HttpApiSchema,
|
||||
OpenApi,
|
||||
} from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const root = "/session"
|
||||
const ListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
})
|
||||
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
|
||||
const MessagesQuery = Schema.Struct({
|
||||
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||
before: Schema.optional(Schema.String),
|
||||
})
|
||||
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
|
||||
const UpdatePayload = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
archived: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "SessionUpdateInput" })
|
||||
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionForkInput",
|
||||
})
|
||||
const InitPayload = Schema.Struct({
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
messageID: MessageID,
|
||||
}).annotate({ identifier: "SessionInitInput" })
|
||||
const SummarizePayload = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
auto: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "SessionSummarizeInput" })
|
||||
const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionPromptInput",
|
||||
})
|
||||
const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionCommandInput",
|
||||
})
|
||||
const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionShellInput",
|
||||
})
|
||||
const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionRevertInput",
|
||||
})
|
||||
const PermissionResponsePayload = Schema.Struct({
|
||||
response: Permission.Reply,
|
||||
}).annotate({ identifier: "SessionPermissionResponseInput" })
|
||||
|
||||
export const SessionPaths = {
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
get: `${root}/:sessionID`,
|
||||
children: `${root}/:sessionID/children`,
|
||||
todo: `${root}/:sessionID/todo`,
|
||||
diff: `${root}/:sessionID/diff`,
|
||||
messages: `${root}/:sessionID/message`,
|
||||
message: `${root}/:sessionID/message/:messageID`,
|
||||
create: root,
|
||||
remove: `${root}/:sessionID`,
|
||||
update: `${root}/:sessionID`,
|
||||
fork: `${root}/:sessionID/fork`,
|
||||
abort: `${root}/:sessionID/abort`,
|
||||
share: `${root}/:sessionID/share`,
|
||||
init: `${root}/:sessionID/init`,
|
||||
summarize: `${root}/:sessionID/summarize`,
|
||||
prompt: `${root}/:sessionID/message`,
|
||||
promptAsync: `${root}/:sessionID/prompt_async`,
|
||||
command: `${root}/:sessionID/command`,
|
||||
shell: `${root}/:sessionID/shell`,
|
||||
revert: `${root}/:sessionID/revert`,
|
||||
unrevert: `${root}/:sessionID/unrevert`,
|
||||
permissions: `${root}/:sessionID/permissions/:permissionID`,
|
||||
deleteMessage: `${root}/:sessionID/message/:messageID`,
|
||||
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
} as const
|
||||
|
||||
export const SessionApi = HttpApi.make("session")
|
||||
.add(
|
||||
HttpApiGroup.make("session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", SessionPaths.list, {
|
||||
query: ListQuery,
|
||||
success: Schema.Array(Session.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.list",
|
||||
summary: "List sessions",
|
||||
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
success: StatusMap,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.status",
|
||||
summary: "Get session status",
|
||||
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.get",
|
||||
summary: "Get session",
|
||||
description: "Retrieve detailed information about a specific OpenCode session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Array(Session.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.children",
|
||||
summary: "Get session children",
|
||||
description: "Retrieve all child sessions that were forked from the specified parent session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Array(Todo.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.todo",
|
||||
summary: "Get session todos",
|
||||
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("diff", SessionPaths.diff, {
|
||||
params: { sessionID: SessionID },
|
||||
query: DiffQuery,
|
||||
success: Schema.Array(Snapshot.FileDiff),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.diff",
|
||||
summary: "Get message diff",
|
||||
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("messages", SessionPaths.messages, {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
success: Schema.Array(MessageV2.WithParts),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.messages",
|
||||
summary: "Get session messages",
|
||||
description: "Retrieve all messages in a session, including user prompts and AI responses.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.message",
|
||||
summary: "Get message",
|
||||
description: "Retrieve a specific message from a session by its message ID.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||
payload: Session.CreateInput,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.create",
|
||||
summary: "Create session",
|
||||
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.delete",
|
||||
summary: "Delete session",
|
||||
description: "Delete a session and permanently remove all associated data, including messages and history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: UpdatePayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.update",
|
||||
summary: "Update session",
|
||||
description: "Update properties of an existing session, such as title or other metadata.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ForkPayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.fork",
|
||||
summary: "Fork session",
|
||||
description: "Create a new session by forking an existing session at a specific message point.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.abort",
|
||||
summary: "Abort session",
|
||||
description: "Abort an active session and stop any ongoing AI processing or command execution.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: InitPayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.init",
|
||||
summary: "Initialize session",
|
||||
description:
|
||||
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.share",
|
||||
summary: "Share session",
|
||||
description: "Create a shareable link for a session, allowing others to view the conversation.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unshare",
|
||||
summary: "Unshare session",
|
||||
description: "Remove the shareable link for a session, making it private again.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: SummarizePayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.summarize",
|
||||
summary: "Summarize session",
|
||||
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt",
|
||||
summary: "Send message",
|
||||
description: "Create and send a new message to a session, streaming the AI response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt_async",
|
||||
summary: "Send async message",
|
||||
description:
|
||||
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: CommandPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.command",
|
||||
summary: "Send command",
|
||||
description: "Send a new command to a session for execution by the AI assistant.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ShellPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.shell",
|
||||
summary: "Run shell command",
|
||||
description: "Execute a shell command within the session context and return the AI's response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: RevertPayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.revert",
|
||||
summary: "Revert message",
|
||||
description:
|
||||
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unrevert",
|
||||
summary: "Restore reverted messages",
|
||||
description: "Restore all previously reverted messages in a session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||
payload: PermissionResponsePayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.respond",
|
||||
summary: "Respond to permission",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.deleteMessage",
|
||||
summary: "Delete message",
|
||||
description:
|
||||
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.delete",
|
||||
description: "Delete a part from a message.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
payload: MessageV2.Part,
|
||||
success: MessageV2.Part,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.update",
|
||||
description: "Update a part in a message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
description: "Experimental HttpApi session routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const sessionHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const statusSvc = yield* SessionStatus.Service
|
||||
const todoSvc = yield* Todo.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
|
||||
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return Instance.restore(instance, () =>
|
||||
Array.from(
|
||||
Session.list({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots === "true" ? true : undefined,
|
||||
start: ctx.query.start,
|
||||
search: ctx.query.search,
|
||||
limit: ctx.query.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("SessionHttpApi.status")(function* () {
|
||||
return Object.fromEntries(yield* statusSvc.list())
|
||||
})
|
||||
|
||||
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* session.get(ctx.params.sessionID)
|
||||
})
|
||||
|
||||
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* session.children(ctx.params.sessionID)
|
||||
})
|
||||
|
||||
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* todoSvc.get(ctx.params.sessionID)
|
||||
})
|
||||
|
||||
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
query: typeof DiffQuery.Type
|
||||
}) {
|
||||
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
|
||||
})
|
||||
|
||||
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
query: typeof MessagesQuery.Type
|
||||
}) {
|
||||
if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
|
||||
if (ctx.query.before !== undefined) {
|
||||
const before = ctx.query.before
|
||||
yield* Effect.try({
|
||||
try: () => MessageV2.cursor.decode(before),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
}
|
||||
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
|
||||
yield* session.get(ctx.params.sessionID)
|
||||
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
}
|
||||
|
||||
const page = MessageV2.page({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit,
|
||||
before: ctx.query.before,
|
||||
})
|
||||
if (!page.cursor) return page.items
|
||||
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
url.searchParams.set("limit", ctx.query.limit.toString())
|
||||
url.searchParams.set("before", page.cursor)
|
||||
return HttpServerResponse.jsonUnsafe(page.items, {
|
||||
headers: {
|
||||
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
|
||||
Link: `<${url.toString()}>; rel="next"`,
|
||||
"X-Next-Cursor": page.cursor,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
|
||||
params: { sessionID: SessionID; messageID: MessageID }
|
||||
}) {
|
||||
return yield* Effect.sync(() =>
|
||||
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
|
||||
)
|
||||
})
|
||||
|
||||
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload: Session.CreateInput }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const update = Effect.fn("SessionHttpApi.update")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof UpdatePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* svc.get(ctx.params.sessionID)
|
||||
if (ctx.payload.title !== undefined) {
|
||||
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
||||
}
|
||||
if (ctx.payload.permission !== undefined) {
|
||||
yield* svc.setPermission({
|
||||
sessionID: ctx.params.sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
|
||||
})
|
||||
}
|
||||
if (ctx.payload.time?.archived !== undefined) {
|
||||
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
|
||||
}
|
||||
return yield* svc.get(ctx.params.sessionID)
|
||||
}),
|
||||
).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof ForkPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
|
||||
).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
|
||||
Effect.provide(SessionPrompt.defaultLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof InitPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(ctx.params.sessionID)
|
||||
return yield* session.get(ctx.params.sessionID)
|
||||
}).pipe(Effect.provide(SessionShare.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(ctx.params.sessionID)
|
||||
return yield* session.get(ctx.params.sessionID)
|
||||
}).pipe(Effect.provide(SessionShare.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof SummarizePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
|
||||
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
const defaultAgent = yield* agent.defaultAgent()
|
||||
const currentAgent =
|
||||
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
|
||||
|
||||
yield* compact.create({
|
||||
sessionID: ctx.params.sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: ctx.payload.providerID,
|
||||
modelID: ctx.payload.modelID,
|
||||
},
|
||||
auto: ctx.payload.auto ?? false,
|
||||
})
|
||||
yield* prompt.loop({ sessionID: ctx.params.sessionID })
|
||||
}).pipe(
|
||||
Effect.provide(SessionRevert.defaultLayer),
|
||||
Effect.provide(SessionCompaction.defaultLayer),
|
||||
Effect.provide(SessionPrompt.defaultLayer),
|
||||
Effect.provide(Agent.defaultLayer),
|
||||
Effect.provide(Session.defaultLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof PromptPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
} as unknown as SessionPrompt.PromptInput),
|
||||
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Stream.map((message) => JSON.stringify(message)),
|
||||
Stream.encodeText,
|
||||
),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
})
|
||||
|
||||
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof PromptPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.sync(() => {
|
||||
Instance.restore(instance, () => {
|
||||
void AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
|
||||
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
|
||||
).catch((error) => {
|
||||
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
|
||||
void Bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.params.sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
return HttpApiSchema.NoContent.make()
|
||||
})
|
||||
|
||||
const command = Effect.fn("SessionHttpApi.command")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
|
||||
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof ShellPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
|
||||
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof RevertPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
log.info("revert", ctx.payload)
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
|
||||
Effect.provide(SessionRevert.defaultLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
|
||||
Effect.provide(SessionRevert.defaultLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
|
||||
params: { permissionID: PermissionID }
|
||||
payload: typeof PermissionResponsePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
|
||||
).pipe(Effect.provide(Permission.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
|
||||
params: { sessionID: SessionID; messageID: MessageID }
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(ctx.params.sessionID)
|
||||
yield* session.removeMessage(ctx.params)
|
||||
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
|
||||
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
|
||||
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
|
||||
payload: typeof MessageV2.Part.Type
|
||||
}) {
|
||||
const payload = ctx.payload as MessageV2.Part
|
||||
if (
|
||||
payload.id !== ctx.params.partID ||
|
||||
payload.messageID !== ctx.params.messageID ||
|
||||
payload.sessionID !== ctx.params.sessionID
|
||||
) {
|
||||
throw new Error(
|
||||
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
||||
handlers
|
||||
.handle("list", list)
|
||||
.handle("status", status)
|
||||
.handle("get", get)
|
||||
.handle("children", children)
|
||||
.handle("todo", todo)
|
||||
.handle("diff", diff)
|
||||
.handle("messages", messages)
|
||||
.handle("message", message)
|
||||
.handle("create", create)
|
||||
.handle("remove", remove)
|
||||
.handle("update", update)
|
||||
.handle("fork", fork)
|
||||
.handle("abort", abort)
|
||||
.handle("init", init)
|
||||
.handle("share", share)
|
||||
.handle("unshare", unshare)
|
||||
.handle("summarize", summarize)
|
||||
.handle("prompt", prompt)
|
||||
.handle("promptAsync", promptAsync)
|
||||
.handle("command", command)
|
||||
.handle("shell", shell)
|
||||
.handle("revert", revert)
|
||||
.handle("unrevert", unrevert)
|
||||
.handle("permissionRespond", permissionRespond)
|
||||
.handle("deleteMessage", deleteMessage)
|
||||
.handle("deletePart", deletePart)
|
||||
.handle("updatePart", updatePart),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Database, asc, and, eq, lte, not, or } from "@/storage"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/sync"
|
||||
const ReplayEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregateID: Schema.String,
|
||||
seq: Schema.Number,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "SyncReplayEvent" })
|
||||
const ReplayPayload = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
events: Schema.NonEmptyArray(ReplayEvent),
|
||||
}).annotate({ identifier: "SyncReplayInput" })
|
||||
const ReplayResponse = Schema.Struct({
|
||||
sessionID: Schema.String,
|
||||
}).annotate({ identifier: "SyncReplayResponse" })
|
||||
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
|
||||
const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregate_id: Schema.String,
|
||||
seq: Schema.Number,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "SyncHistoryEvent" })
|
||||
|
||||
export const SyncPaths = {
|
||||
start: `${root}/start`,
|
||||
replay: `${root}/replay`,
|
||||
history: `${root}/history`,
|
||||
} as const
|
||||
|
||||
export const SyncApi = HttpApi.make("sync")
|
||||
.add(
|
||||
HttpApiGroup.make("sync")
|
||||
.add(
|
||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.start",
|
||||
summary: "Start workspace sync",
|
||||
description: "Start sync loops for workspaces in the current project that have active sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||
payload: ReplayPayload,
|
||||
success: ReplayResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.replay",
|
||||
summary: "Replay sync events",
|
||||
description: "Validate and replay a complete sync event history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||
payload: HistoryPayload,
|
||||
success: Schema.Array(HistoryEvent),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.history.list",
|
||||
summary: "List sync events",
|
||||
description:
|
||||
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "sync",
|
||||
description: "Experimental HttpApi sync routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const syncHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const start = Effect.fn("SyncHttpApi.start")(function* () {
|
||||
startWorkspaceSyncing((yield* InstanceState.context).project.id)
|
||||
return true
|
||||
})
|
||||
|
||||
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
|
||||
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregateID,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: { ...event.data },
|
||||
}))
|
||||
SyncEvent.replayAll(events)
|
||||
return { sessionID: events[0].aggregateID }
|
||||
})
|
||||
|
||||
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
||||
const exclude = Object.entries(ctx.payload)
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
exclude.length > 0
|
||||
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
|
||||
handlers.handle("start", start).handle("replay", replay).handle("history", history),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -2,15 +2,30 @@ import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Effect, Layer, Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
|
||||
identifier: "WorkspaceCreateInput",
|
||||
})
|
||||
const SessionRestorePayload = Schema.Struct(
|
||||
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
|
||||
).annotate({
|
||||
identifier: "WorkspaceSessionRestoreInput",
|
||||
})
|
||||
const SessionRestoreResponse = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
|
||||
|
||||
export const WorkspacePaths = {
|
||||
adaptors: `${root}/adaptor`,
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
sessionRestore: `${root}/:id/session-restore`,
|
||||
} as const
|
||||
|
||||
export const WorkspaceApi = HttpApi.make("workspace")
|
||||
@@ -35,6 +50,16 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
description: "List all workspaces.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", WorkspacePaths.list, {
|
||||
payload: CreatePayload,
|
||||
success: Workspace.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.create",
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
success: Schema.Array(Workspace.ConnectionStatus),
|
||||
}).annotateMerge(
|
||||
@@ -44,6 +69,27 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
success: Schema.UndefinedOr(Workspace.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.remove",
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
payload: SessionRestorePayload,
|
||||
success: SessionRestoreResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.sessionRestore",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -72,13 +118,51 @@ export const workspaceHandlers = Layer.unwrap(
|
||||
return Workspace.list((yield* InstanceState.context).project)
|
||||
})
|
||||
|
||||
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.create({
|
||||
...ctx.payload,
|
||||
projectID: instance.project.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
|
||||
return Workspace.status().filter((item) => ids.has(item.workspaceID))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
|
||||
})
|
||||
|
||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
||||
params: { id: Workspace.Info["id"] }
|
||||
payload: typeof SessionRestorePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.sessionRestore({
|
||||
workspaceID: ctx.params.id,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
|
||||
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
|
||||
handlers
|
||||
.handle("adaptors", adaptors)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("sessionRestore", sessionRestore),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16,9 +16,12 @@ import { QuestionRoutes } from "./question"
|
||||
import { PermissionRoutes } from "./permission"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||
import { ExperimentalPaths } from "./httpapi/experimental"
|
||||
import { FilePaths } from "./httpapi/file"
|
||||
import { InstancePaths } from "./httpapi/instance"
|
||||
import { McpPaths } from "./httpapi/mcp"
|
||||
import { SessionPaths } from "./httpapi/session"
|
||||
import { SyncPaths } from "./httpapi/sync"
|
||||
import { ProjectRoutes } from "./project"
|
||||
import { SessionRoutes } from "./session"
|
||||
import { PtyRoutes } from "./pty"
|
||||
@@ -44,20 +47,80 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
app.get("/permission", (c) => handler(c.req.raw, context))
|
||||
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.get("/config", (c) => handler(c.req.raw, context))
|
||||
app.patch("/config", (c) => handler(c.req.raw, context))
|
||||
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||
app.get("/provider", (c) => handler(c.req.raw, context))
|
||||
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
|
||||
app.get("/project", (c) => handler(c.req.raw, context))
|
||||
app.get("/project/current", (c) => handler(c.req.raw, context))
|
||||
app.post("/project/git/init", (c) => handler(c.req.raw, context))
|
||||
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
|
||||
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
|
||||
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -201,7 +264,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
description: "List of commands",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Command.Info.array()),
|
||||
schema: resolver(Command.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -224,7 +287,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
description: "List of agents",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Agent.Info.array()),
|
||||
schema: resolver(Agent.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -247,7 +310,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
description: "List of skills",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Skill.Info.array()),
|
||||
schema: resolver(Skill.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -293,7 +356,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
description: "Formatter status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Format.Status.array()),
|
||||
schema: resolver(Format.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,7 +67,10 @@ function create(opts: { cors?: string[] }) {
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
|
||||
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
workspaceApp.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
|
||||
workspaceApp.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
|
||||
workspaceApp.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
workspaceApp.route("/", workspaceLegacyApp)
|
||||
|
||||
|
||||
@@ -373,6 +373,7 @@ const live: Layer.Layer<
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
: {
|
||||
"x-session-affinity": input.sessionID,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { LSP } from "../lsp"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Command } from "../command"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
|
||||
@@ -2,7 +2,9 @@ import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -23,13 +25,13 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
location: Schema.String,
|
||||
content: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
@@ -91,7 +93,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
const parsed = z.object({ name: z.string(), description: z.string() }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
||||
@@ -575,9 +576,10 @@ export const BashTool = Tool.define(
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
const limits = yield* trunc.limits()
|
||||
const instance = yield* InstanceState.context
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
description: DESCRIPTION.replaceAll("${directory}", instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
|
||||
@@ -34,7 +34,7 @@ import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
@@ -18,8 +18,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "worktree" })
|
||||
|
||||
@@ -39,48 +41,38 @@ export const Event = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
branch: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Worktree",
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
branch: Schema.String,
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "Worktree" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const CreateInput = Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
startCommand: Schema.optional(
|
||||
Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
|
||||
),
|
||||
})
|
||||
.annotate({ identifier: "WorktreeCreateInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const CreateInput = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
|
||||
})
|
||||
.meta({
|
||||
ref: "WorktreeCreateInput",
|
||||
})
|
||||
export const RemoveInput = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "WorktreeRemoveInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
|
||||
|
||||
export type CreateInput = z.infer<typeof CreateInput>
|
||||
|
||||
export const RemoveInput = z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "WorktreeRemoveInput",
|
||||
})
|
||||
|
||||
export type RemoveInput = z.infer<typeof RemoveInput>
|
||||
|
||||
export const ResetInput = z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "WorktreeResetInput",
|
||||
})
|
||||
|
||||
export type ResetInput = z.infer<typeof ResetInput>
|
||||
export const ResetInput = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "WorktreeResetInput" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
|
||||
|
||||
export const NotGitError = NamedError.create(
|
||||
"WorktreeNotGitError",
|
||||
@@ -210,7 +202,7 @@ export const layer: Layer.Layer<
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||
if (branchCheck.code === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
return { name, branch, directory }
|
||||
}
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "../../src/auth"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
|
||||
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
|
||||
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Npm } from "../../../src/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
||||
@@ -27,7 +27,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Format } from "../../src/format"
|
||||
import * as Formatter from "../../src/format/formatter"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { LSPServer } from "../../src/lsp"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { LSPServer } from "../../src/lsp"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Npm } from "../src/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
@@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Npm } = await import("../../src/npm")
|
||||
const { Npm } = await import("@opencode-ai/core/npm")
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
|
||||
@@ -55,6 +55,14 @@ async function defaultModel() {
|
||||
return run((provider) => provider.defaultModel())
|
||||
}
|
||||
|
||||
async function markPluginDependenciesReady(dir: string) {
|
||||
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "package-lock.json"),
|
||||
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
|
||||
)
|
||||
}
|
||||
|
||||
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||
const item = providers[ProviderID.make("opencode")]
|
||||
expect(item).toBeDefined()
|
||||
@@ -2439,8 +2447,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
||||
test("plugin config providers persist after instance dispose", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const root = path.join(dir, ".opencode", "plugin")
|
||||
const configDir = path.join(dir, ".opencode")
|
||||
const root = path.join(configDir, "plugin")
|
||||
await mkdir(root, { recursive: true })
|
||||
await markPluginDependenciesReady(configDir)
|
||||
await markPluginDependenciesReady(Global.Path.config)
|
||||
await Bun.write(
|
||||
path.join(root, "demo-provider.ts"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import path from "path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("config HttpApi", () => {
|
||||
test("serves config update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/config", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ username: "patched-user", formatter: false, lsp: false })
|
||||
await disposed
|
||||
expect(await Bun.file(path.join(tmp.path, "config.json")).json()).toMatchObject({
|
||||
username: "patched-user",
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||
import { Session } from "../../src/session"
|
||||
import { Database } from "../../src/storage"
|
||||
import { Log } from "../../src/util"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
const testWorktreeMutations = process.platform === "win32" ? test.skip : test
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return runSession(Session.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
async function waitReady(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for worktree.ready"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("experimental HttpApi", () => {
|
||||
test("serves read-only experimental endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = await Promise.all([
|
||||
app().request(ExperimentalPaths.console, { headers }),
|
||||
app().request(ExperimentalPaths.consoleOrgs, { headers }),
|
||||
app().request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, { headers }),
|
||||
app().request(ExperimentalPaths.toolIDs, { headers }),
|
||||
app().request(ExperimentalPaths.worktree, { headers }),
|
||||
app().request(ExperimentalPaths.resource, { headers }),
|
||||
])
|
||||
|
||||
expect(consoleState.status).toBe(200)
|
||||
expect(await consoleState.json()).toEqual({
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
|
||||
expect(consoleOrgs.status).toBe(200)
|
||||
expect(await consoleOrgs.json()).toEqual({ orgs: [] })
|
||||
|
||||
expect(toolList.status).toBe(200)
|
||||
expect(await toolList.json()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "bash",
|
||||
description: expect.any(String),
|
||||
parameters: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(toolIDs.status).toBe(200)
|
||||
expect(await toolIDs.json()).toContain("bash")
|
||||
|
||||
expect(worktrees.status).toBe(200)
|
||||
expect(await worktrees.json()).toEqual([])
|
||||
|
||||
expect(resources.status).toBe(200)
|
||||
expect(await resources.json()).toEqual({})
|
||||
})
|
||||
|
||||
test("serves Console org switch through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
Database.Client()
|
||||
.$client.prepare(
|
||||
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"account-test",
|
||||
"test@example.com",
|
||||
"https://console.example.com",
|
||||
"access",
|
||||
"refresh",
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
)
|
||||
|
||||
const switched = await app().request(ExperimentalPaths.consoleSwitch, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ accountID: "account-test", orgID: "org-test" }),
|
||||
})
|
||||
|
||||
expect(switched.status).toBe(200)
|
||||
expect(await switched.json()).toBe(true)
|
||||
})
|
||||
|
||||
test("serves global session list through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
const first = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => createSession({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => createSession({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const page = await app().request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "1" })}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||
|
||||
const body = (await page.json()) as Session.GlobalInfo[]
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
expect(body[0].project?.id).toBe(second.projectID)
|
||||
|
||||
const next = await app().request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({
|
||||
directory: tmp.path,
|
||||
limit: "10",
|
||||
cursor: body[0].time.updated.toString(),
|
||||
})}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(next.status).toBe(200)
|
||||
expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id)
|
||||
})
|
||||
|
||||
testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const created = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: "api-test" }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
const info = (await created.json()) as Worktree.Info
|
||||
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
|
||||
await waitReady(info.directory)
|
||||
|
||||
const listed = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toContain(info.directory)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const reset = await app().request(ExperimentalPaths.worktreeReset, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(reset.status).toBe(200)
|
||||
expect(await reset.json()).toBe(true)
|
||||
}
|
||||
|
||||
const removed = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toBe(true)
|
||||
|
||||
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(afterRemove.status).toBe(200)
|
||||
expect(await afterRemove.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -54,4 +54,24 @@ describe("file HttpApi", () => {
|
||||
expect(status.status).toBe(200)
|
||||
expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
|
||||
})
|
||||
|
||||
test("serves search endpoints", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
|
||||
|
||||
const [text, files, symbols] = await Promise.all([
|
||||
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
|
||||
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
|
||||
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
|
||||
])
|
||||
|
||||
expect(text.status).toBe(200)
|
||||
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
|
||||
|
||||
expect(files.status).toBe(200)
|
||||
expect(await files.json()).toContain("hello.txt")
|
||||
|
||||
expect(symbols.status).toBe(200)
|
||||
expect(await symbols.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import path from "path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
|
||||
@@ -19,6 +20,24 @@ function app() {
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
@@ -50,4 +69,98 @@ describe("instance HttpApi", () => {
|
||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves catalog read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const [commands, agents, skills, lsp, formatter] = await Promise.all([
|
||||
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
|
||||
expect(commands.status).toBe(200)
|
||||
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
|
||||
|
||||
expect(agents.status).toBe(200)
|
||||
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
|
||||
|
||||
expect(skills.status).toBe(200)
|
||||
expect(await skills.json()).toBeArray()
|
||||
|
||||
expect(lsp.status).toBe(200)
|
||||
expect(await lsp.json()).toEqual([])
|
||||
|
||||
expect(formatter.status).toBe(200)
|
||||
expect(await formatter.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves project git init through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
await disposed
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("serves project update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
const project = (await current.json()) as { id: string }
|
||||
|
||||
const response = await app().request(`/project/${project.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
id: project.id,
|
||||
name: "patched-project",
|
||||
commands: { start: "bun dev" },
|
||||
})
|
||||
|
||||
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toContainEqual(
|
||||
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves instance dispose through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const disposed = new Promise<string | undefined>((resolve) => {
|
||||
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve(event.directory)
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
|
||||
const response = await app().request(InstancePaths.dispose, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toBe(true)
|
||||
expect(await disposed).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,12 +11,13 @@ void Log.init({ print: false })
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function request(route: string, directory: string) {
|
||||
function request(route: string, directory: string, init?: RequestInit) {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
headers: {
|
||||
"x-opencode-directory": directory,
|
||||
},
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
@@ -45,4 +46,65 @@ describe("mcp HttpApi", () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ demo: { status: "disabled" } })
|
||||
})
|
||||
|
||||
test("serves add, connect, and disconnect endpoints", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const added = await request(McpPaths.status, tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "added",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["echo", "added"],
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(added.status).toBe(200)
|
||||
expect(await added.json()).toMatchObject({ added: { status: "disabled" } })
|
||||
|
||||
const connected = await request("/mcp/demo/connect", tmp.path, { method: "POST" })
|
||||
expect(connected.status).toBe(200)
|
||||
expect(await connected.json()).toBe(true)
|
||||
|
||||
const disconnected = await request("/mcp/demo/disconnect", tmp.path, { method: "POST" })
|
||||
expect(disconnected.status).toBe(200)
|
||||
expect(await disconnected.json()).toBe(true)
|
||||
})
|
||||
|
||||
test("serves deterministic OAuth endpoints", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const start = await request("/mcp/demo/auth", tmp.path, { method: "POST" })
|
||||
expect(start.status).toBe(400)
|
||||
|
||||
const authenticate = await request("/mcp/demo/auth/authenticate", tmp.path, { method: "POST" })
|
||||
expect(authenticate.status).toBe(400)
|
||||
|
||||
const removed = await request("/mcp/demo/auth", tmp.path, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
}
|
||||
|
||||
async function createSession(directory: string, input?: Session.CreateInput) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => runSession(Session.Service.use((svc) => svc.create(input))),
|
||||
})
|
||||
}
|
||||
|
||||
async function createTextMessage(directory: string, sessionID: SessionID, text: string) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () =>
|
||||
runSession(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Session.Service
|
||||
const info = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return { info, part }
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function json<T>(response: Response) {
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("session HttpApi", () => {
|
||||
test("serves read routes through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const parent = await createSession(tmp.path, { title: "parent" })
|
||||
const child = await createSession(tmp.path, { title: "child", parentID: parent.id })
|
||||
const message = await createTextMessage(tmp.path, parent.id, "hello")
|
||||
await createTextMessage(tmp.path, parent.id, "world")
|
||||
|
||||
expect(
|
||||
(await json<Session.Info[]>(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toContain(parent.id)
|
||||
|
||||
expect(await json<Record<string, unknown>>(await app().request(SessionPaths.status, { headers }))).toEqual({})
|
||||
|
||||
expect(
|
||||
await json<Session.Info>(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers })),
|
||||
).toMatchObject({ id: parent.id, title: "parent" })
|
||||
|
||||
expect(
|
||||
(
|
||||
await json<Session.Info[]>(
|
||||
await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }),
|
||||
)
|
||||
).map((item) => item.id),
|
||||
).toEqual([child.id])
|
||||
|
||||
expect(
|
||||
await json<unknown[]>(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers })),
|
||||
).toEqual([])
|
||||
|
||||
expect(
|
||||
await json<unknown[]>(await app().request(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers })),
|
||||
).toEqual([])
|
||||
|
||||
const messages = await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
|
||||
headers,
|
||||
})
|
||||
const messagePage = await json<MessageV2.WithParts[]>(messages)
|
||||
const nextCursor = messages.headers.get("x-next-cursor")
|
||||
expect(nextCursor).toBeTruthy()
|
||||
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
|
||||
|
||||
expect(
|
||||
(
|
||||
await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
|
||||
headers,
|
||||
})
|
||||
).status,
|
||||
).toBe(400)
|
||||
expect(
|
||||
(
|
||||
await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
|
||||
headers,
|
||||
})
|
||||
).status,
|
||||
).toBe(400)
|
||||
|
||||
expect(
|
||||
await json<MessageV2.WithParts>(
|
||||
await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), {
|
||||
headers,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ info: { id: message.info.id } })
|
||||
})
|
||||
|
||||
test("serves lifecycle mutation routes through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
|
||||
const created = await json<Session.Info>(
|
||||
await app().request(SessionPaths.create, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ title: "created" }),
|
||||
}),
|
||||
)
|
||||
expect(created.title).toBe("created")
|
||||
|
||||
const updated = await json<Session.Info>(
|
||||
await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
|
||||
}),
|
||||
)
|
||||
expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
|
||||
|
||||
const forked = await json<Session.Info>(
|
||||
await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
)
|
||||
expect(forked.id).not.toBe(created.id)
|
||||
|
||||
expect(
|
||||
await json<boolean>(
|
||||
await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
await json<boolean>(
|
||||
await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("serves message mutation routes through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const session = await createSession(tmp.path, { title: "messages" })
|
||||
const first = await createTextMessage(tmp.path, session.id, "first")
|
||||
const second = await createTextMessage(tmp.path, session.id, "second")
|
||||
|
||||
const updated = await json<MessageV2.Part>(
|
||||
await app().request(
|
||||
pathFor(SessionPaths.updatePart, {
|
||||
sessionID: session.id,
|
||||
messageID: first.info.id,
|
||||
partID: first.part.id,
|
||||
}),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ ...first.part, text: "updated" }),
|
||||
},
|
||||
),
|
||||
)
|
||||
expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
|
||||
|
||||
expect(
|
||||
await json<boolean>(
|
||||
await app().request(
|
||||
pathFor(SessionPaths.deletePart, {
|
||||
sessionID: session.id,
|
||||
messageID: first.info.id,
|
||||
partID: first.part.id,
|
||||
}),
|
||||
{ method: "DELETE", headers },
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
await json<boolean>(
|
||||
await app().request(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("serves remaining non-LLM session mutation routes through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const session = await createSession(tmp.path, { title: "remaining" })
|
||||
|
||||
expect(
|
||||
await json<Session.Info>(
|
||||
await app().request(pathFor(SessionPaths.revert, { sessionID: session.id }), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ messageID: MessageID.ascending() }),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ id: session.id })
|
||||
|
||||
expect(
|
||||
await json<Session.Info>(
|
||||
await app().request(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
|
||||
method: "POST",
|
||||
headers,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ id: session.id })
|
||||
|
||||
expect(
|
||||
await json<boolean>(
|
||||
await app().request(
|
||||
pathFor(SessionPaths.permissions, {
|
||||
sessionID: session.id,
|
||||
permissionID: String(PermissionID.ascending()),
|
||||
}),
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ response: "once" }),
|
||||
},
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("sync HttpApi", () => {
|
||||
test("serves sync routes through Hono bridge", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
|
||||
const session = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
|
||||
})
|
||||
|
||||
const started = await app().request(SyncPaths.start, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(await started.json()).toBe(true)
|
||||
|
||||
const history = await app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (await history.json()) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = await app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.path,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -10,19 +17,50 @@ import { Instance } from "../../src/project/instance"
|
||||
void Log.init({ print: false })
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function request(path: string, directory: string) {
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${path}`, {
|
||||
headers: {
|
||||
"x-opencode-directory": directory,
|
||||
},
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function localAdaptor(directory: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
name: "local-test",
|
||||
directory,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
@@ -52,4 +90,43 @@ describe("workspace HttpApi", () => {
|
||||
expect(status.status).toBe(200)
|
||||
expect(await status.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves mutation endpoints", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () =>
|
||||
registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))),
|
||||
})
|
||||
|
||||
const created = await request(WorkspacePaths.list, tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const workspace = (await created.json()) as Workspace.Info
|
||||
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
||||
|
||||
const session = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
|
||||
})
|
||||
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
})
|
||||
expect(restored.status).toBe(200)
|
||||
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
|
||||
|
||||
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toMatchObject({ id: workspace.id })
|
||||
|
||||
const listed = await request(WorkspacePaths.list, tmp.path)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ import * as SessionProcessorModule from "../../src/session/processor"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
||||
@@ -873,6 +873,79 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves OpenRouter reasoning details through provider transform", async () => {
|
||||
const assistantID = "m-assistant"
|
||||
const openrouterModel: Provider.Model = {
|
||||
...model,
|
||||
id: ModelID.make("deepseek/deepseek-v4-pro"),
|
||||
providerID: ProviderID.make("openrouter"),
|
||||
api: {
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
url: "https://openrouter.ai/api/v1",
|
||||
npm: "@openrouter/ai-sdk-provider",
|
||||
},
|
||||
capabilities: {
|
||||
...model.capabilities,
|
||||
reasoning: true,
|
||||
interleaved: { field: "reasoning_details" },
|
||||
},
|
||||
}
|
||||
const reasoningDetails = [
|
||||
{
|
||||
type: "reasoning.text",
|
||||
text: "thinking",
|
||||
format: "unknown",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: assistantInfo(assistantID, "m-parent", undefined, {
|
||||
providerID: openrouterModel.providerID,
|
||||
modelID: openrouterModel.id,
|
||||
}),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1"),
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
time: { start: 0 },
|
||||
metadata: {
|
||||
openrouter: {
|
||||
reasoning_details: reasoningDetails,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a2"),
|
||||
type: "text",
|
||||
text: "answer",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
ProviderTransform.message(await MessageV2.toModelMessages(input, openrouterModel), openrouterModel, {}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
reasoning_details: reasoningDetails,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("splits assistant messages on step-start boundaries", async () => {
|
||||
const assistantID = "m-assistant"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Log } from "../../src/util"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config"
|
||||
import { Provider } from "../../src/provider"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Skill } from "../../src/skill"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Storage } from "../../src/storage"
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Permission } from "../../src/permission"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "../../src/tool"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Layer } from "effect"
|
||||
import { GrepTool } from "../../src/tool/grep"
|
||||
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { QuestionTool } from "../../src/tool/question"
|
||||
import { Question } from "../../src/question"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user