mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 373cc2a5e1 | |||
| a3128e32c5 | |||
| 5f8a72bfc4 | |||
| 418a1cf5f3 | |||
| 60ebd074ac | |||
| 216dd363e8 | |||
| 141f33d24b | |||
| c4d8a8183e | |||
| 58244eb687 | |||
| e9071b0a80 | |||
| c68907ece2 | |||
| af3998c8a6 | |||
| fad2618757 | |||
| 21e01dbe04 | |||
| 3beadeebff | |||
| dcee1c3642 | |||
| 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
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,36 +192,41 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"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:",
|
||||
},
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -254,7 +259,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -298,7 +303,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -327,7 +332,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -343,7 +348,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -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",
|
||||
@@ -489,7 +491,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -504,8 +506,8 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.103",
|
||||
"@opentui/solid": ">=0.1.103",
|
||||
"@opentui/core": ">=0.1.105",
|
||||
"@opentui/solid": ">=0.1.105",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -524,7 +526,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -539,7 +541,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -574,7 +576,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -623,7 +625,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -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:",
|
||||
@@ -684,8 +685,8 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.1.103",
|
||||
"@opentui/solid": "0.1.103",
|
||||
"@opentui/core": "0.1.105",
|
||||
"@opentui/solid": "0.1.105",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
@@ -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=="],
|
||||
|
||||
@@ -1612,21 +1613,21 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.103", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.103", "@opentui/core-darwin-x64": "0.1.103", "@opentui/core-linux-arm64": "0.1.103", "@opentui/core-linux-x64": "0.1.103", "@opentui/core-win32-arm64": "0.1.103", "@opentui/core-win32-x64": "0.1.103", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-PWVv/bDmlk1i6X1f0zXs+jSaTrQ/ByX8wFbP2WinOObTGf//UbcRP4dbWxPXvOyka9QlmRBG/7GbloQSIStyVw=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.103", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lxCyedDkcen12IgBtXjkJ7iY66xa7VC4nxRNKCUeLY2ZP9hUE1AsDtbyQzqY+BQadsI/ZME9STzaHDCUFg0TpA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.103", "", { "os": "darwin", "cpu": "x64" }, "sha512-QMYD+zUDGQliJ6m5nuNvA72jtluFeyVMoHkuA5m/Xmed/u8eLfahAKmDj3kY66ntUroPHWevcpbpvd7NCFEoFQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.103", "", { "os": "linux", "cpu": "arm64" }, "sha512-GzOvNr9dN6JaQ9qs7m8E75wLAHwT5CyxqkE6rEr1BO23/d2Ix7e3GYw/JRY5VnTge+eXrfDVbqNtPcQamUNiEA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.103", "", { "os": "linux", "cpu": "x64" }, "sha512-odywllco5zUKNc60uD3JKaCybK64u6BfmpScs4a8Qn89yH/yk23bzWXDRWaGgQdY65L2/VCbcGs1ezA1S/2YTw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.103", "", { "os": "win32", "cpu": "arm64" }, "sha512-tZL5w3Y0JnO7RkIvfuNDAzJn0j6+JIYl6M8DgPM5p8AQt+162S8LmbumzmqQLZl4cEev2eN7/tw72WIk6b+/CQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.103", "", { "os": "win32", "cpu": "x64" }, "sha512-wqnibt/OE5ldSzVPxEbriA0TjI2B11CJl4uJoLxTZ47KDx7tAFIMdhBf9IRAGNCSbcDuZ8ZGEFhV+SLaftkrlw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.103", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.103", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-L28WFBs17Z5JXkJhPagLy8tUamkttDaaQDdb2KEO01IQ9r81yLBRpYqD/lHp6UoSISXgwQVDQ9yNtxwR1BQZvQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -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-zQOuZkuOLm9xV5YzfcQiWSzvussXVw/dRPw4HhuYsdc=",
|
||||
"aarch64-linux": "sha256-J8Ak9rCCkKlBj0KhdUOklglpR3ntRP2VjtdmEJox6Ro=",
|
||||
"aarch64-darwin": "sha256-r4F6S9CfGR6qxSaSqu9AIjWCEKd/PuT1Wprcbzk0IwY=",
|
||||
"x86_64-darwin": "sha256-I/ztb7VnLbJYzAxf2egN0+YsJcP5yix3tIoHtiuRUXE="
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
|
||||
+2
-3
@@ -34,8 +34,8 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.1.103",
|
||||
"@opentui/solid": "0.1.103",
|
||||
"@opentui/core": "0.1.105",
|
||||
"@opentui/solid": "0.1.105",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -40,6 +42,18 @@ type ThemeOption = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
@@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
@@ -165,6 +175,70 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSdk = useGlobalSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
globalSdk.client.pty
|
||||
.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||
{ initialValue: null as DisplayBackend | null },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = globalSync.data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onDisplayBackendChange = (checked: boolean) => {
|
||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||
if (!update) return
|
||||
void update.finally(() => {
|
||||
void refetchDisplayBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
@@ -243,6 +317,27 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
globalSync.updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
@@ -651,70 +746,32 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<SoundsSection />
|
||||
|
||||
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
||||
{(_) => {
|
||||
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
|
||||
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.desktop.wsl.title")}
|
||||
description={language.t("settings.desktop.wsl.description")}
|
||||
>
|
||||
<div data-action="settings-wsl">
|
||||
<Switch
|
||||
checked={enabled() ?? false}
|
||||
disabled={enabledResource.state === "pending"}
|
||||
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>*/}
|
||||
|
||||
<UpdatesSection />
|
||||
|
||||
<Show when={linux()}>
|
||||
{(_) => {
|
||||
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
|
||||
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
const onChange = (checked: boolean) =>
|
||||
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -245,7 +245,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||
}
|
||||
if (loading || input.store.provider.all.length === 0) {
|
||||
input.setStore("provider_ready", false)
|
||||
@@ -265,7 +265,8 @@ export async function bootstrapDirectory(input: {
|
||||
input.queryClient.ensureQueryData(
|
||||
loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
|
||||
),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
|
||||
@@ -728,6 +728,11 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
|
||||
"settings.general.row.colorScheme.title": "Color scheme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { getHandler, optionsHandler } from "../../util/modelsHandler"
|
||||
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return optionsHandler()
|
||||
}
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
return getHandler({ modelList: "lite" })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function optionsHandler() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getHandler(opts: { modelList: "lite" | "full"; disabledModels?: string[] }) {
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !opts.disabledModels?.includes(id))
|
||||
.filter(([id]) => !id.startsWith("alpha-"))
|
||||
.map(([id, _model]) => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "opencode",
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -3,59 +3,29 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { optionsHandler, getHandler } from "~/routes/zen/util/modelsHandler"
|
||||
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
})
|
||||
return optionsHandler()
|
||||
}
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const zenData = ZenData.list("full")
|
||||
const disabledModels = await authenticate()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !disabledModels.includes(id))
|
||||
.filter(([id]) => !id.startsWith("alpha-"))
|
||||
.map(([id, _model]) => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "opencode",
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async function authenticate() {
|
||||
const disabledModels = await (() => {
|
||||
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
|
||||
if (!apiKey) return []
|
||||
|
||||
const disabledModels = await Database.use((tx) =>
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
|
||||
.innerJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows.map((row) => row.model)),
|
||||
)
|
||||
})()
|
||||
|
||||
return disabledModels
|
||||
}
|
||||
return getHandler({ modelList: "full", disabledModels })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -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(
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.25"
|
||||
version = "1.14.27"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.27",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -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` | `bridged` | SSE via raw Effect HTTP |
|
||||
| `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
|
||||
|
||||
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
|
||||
|
||||
### PTY Routes
|
||||
|
||||
- [x] `GET /pty` - list PTY sessions.
|
||||
- [x] `POST /pty` - create PTY session.
|
||||
- [x] `GET /pty/:ptyID` - get PTY session.
|
||||
- [x] `PUT /pty/:ptyID` - update PTY session.
|
||||
- [x] `DELETE /pty/:ptyID` - remove PTY session.
|
||||
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
|
||||
|
||||
### TUI Routes
|
||||
|
||||
- [x] `POST /tui/append-prompt` - append prompt.
|
||||
- [x] `POST /tui/open-help` - open help.
|
||||
- [x] `POST /tui/open-sessions` - open sessions.
|
||||
- [x] `POST /tui/open-themes` - open themes.
|
||||
- [x] `POST /tui/open-models` - open models.
|
||||
- [x] `POST /tui/submit-prompt` - submit prompt.
|
||||
- [x] `POST /tui/clear-prompt` - clear prompt.
|
||||
- [x] `POST /tui/execute-command` - execute command.
|
||||
- [x] `POST /tui/show-toast` - show toast.
|
||||
- [x] `POST /tui/publish` - publish TUI event.
|
||||
- [x] `POST /tui/select-session` - select session.
|
||||
- [x] `GET /tui/control/next` - get next TUI request.
|
||||
- [x] `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. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
||||
13. [x] 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,
|
||||
|
||||
@@ -29,7 +29,8 @@ import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { useConnected } from "@tui/component/use-connected"
|
||||
import { DialogMcp } from "@tui/component/dialog-mcp"
|
||||
import { DialogStatus } from "@tui/component/dialog-status"
|
||||
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
|
||||
@@ -8,13 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
return createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
}
|
||||
import { useConnected } from "./use-connected"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useKeyboard } from "@opentui/solid"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -30,6 +31,7 @@ export function createDialogProviderOptions() {
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const onboarded = useConnected()
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
@@ -49,7 +51,7 @@ export function createDialogProviderOptions() {
|
||||
}[provider.id],
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
gutter: connected ? <text fg={theme.success}>✓</text> : undefined,
|
||||
gutter: connected && onboarded() ? <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
@@ -17,8 +16,6 @@ type Adaptor = {
|
||||
description: string
|
||||
}
|
||||
|
||||
const log = Log.Default.clone().tag("service", "tui-workspace")
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
@@ -37,18 +34,9 @@ export async function openWorkspaceSession(input: {
|
||||
workspaceID: string
|
||||
}) {
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
log.info("workspace session create requested", {
|
||||
workspaceID: input.workspaceID,
|
||||
})
|
||||
|
||||
while (true) {
|
||||
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
||||
log.error("workspace session create request failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
error: errorData(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
@@ -56,24 +44,11 @@ export async function openWorkspaceSession(input: {
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("workspace session create response", {
|
||||
workspaceID: input.workspaceID,
|
||||
status: result.response?.status,
|
||||
sessionID: result.data?.id,
|
||||
})
|
||||
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
|
||||
log.warn("workspace session create retrying after server error", {
|
||||
workspaceID: input.workspaceID,
|
||||
status: result.response.status,
|
||||
})
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
log.error("workspace session create returned no data", {
|
||||
workspaceID: input.workspaceID,
|
||||
status: result.response?.status,
|
||||
})
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
@@ -85,10 +60,6 @@ export async function openWorkspaceSession(input: {
|
||||
type: "session",
|
||||
sessionID: result.data.id,
|
||||
})
|
||||
log.info("workspace session create complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: result.data.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
@@ -104,27 +75,10 @@ export async function restoreWorkspaceSession(input: {
|
||||
sessionID: string
|
||||
done?: () => void
|
||||
}) {
|
||||
log.info("session restore requested", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
const result = await input.sdk.client.experimental.workspace
|
||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
||||
.catch((err) => {
|
||||
log.error("session restore request failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
error: errorData(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!result?.data) {
|
||||
log.error("session restore failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
status: result?.response?.status,
|
||||
error: result?.error ? errorData(result.error) : undefined,
|
||||
})
|
||||
input.toast.show({
|
||||
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
@@ -132,33 +86,11 @@ export async function restoreWorkspaceSession(input: {
|
||||
return
|
||||
}
|
||||
|
||||
log.info("session restore response", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
status: result.response?.status,
|
||||
total: result.data.total,
|
||||
})
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
try {
|
||||
await input.sync.bootstrap({ fatal: false })
|
||||
} catch (e) {}
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
|
||||
log.error("session restore refresh failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
error: errorData(err),
|
||||
})
|
||||
throw err
|
||||
})
|
||||
|
||||
log.info("session restore complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
total: result.data.total,
|
||||
})
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
|
||||
|
||||
input.toast.show({
|
||||
message: "Session restored into the new workspace",
|
||||
@@ -230,47 +162,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
const create = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
log.info("workspace create requested", {
|
||||
type,
|
||||
})
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
log.error("workspace create request failed", {
|
||||
type,
|
||||
error: errorData(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
log.error("workspace create failed", {
|
||||
type,
|
||||
status: result?.response.status,
|
||||
error: result?.error ? errorData(result.error) : undefined,
|
||||
})
|
||||
toast.show({
|
||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("workspace create response", {
|
||||
type,
|
||||
workspaceID: workspace.id,
|
||||
status: result.response?.status,
|
||||
})
|
||||
|
||||
await project.workspace.sync()
|
||||
log.info("workspace create synced", {
|
||||
type,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
return createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -252,12 +278,16 @@ function resolveEditorLockFile() {
|
||||
}
|
||||
|
||||
const cwd = process.cwd()
|
||||
// longest workspace folder that contains cwd; 0 if none match
|
||||
const bestMatchLength = (lock: EditorLockFile) =>
|
||||
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
|
||||
const locks = entries
|
||||
.filter((entry) => entry.endsWith(".lock"))
|
||||
.map((entry) => readEditorLockFile(path.join(directory, entry)))
|
||||
.filter((entry): entry is EditorLockFile => Boolean(entry))
|
||||
.sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
|
||||
|
||||
.filter((entry) => bestMatchLength(entry) > 0)
|
||||
// prefer locks with longer matching workspace folders, then more recent ones
|
||||
.sort((left, right) => bestMatchLength(right) - bestMatchLength(left) || right.mtimeMs - left.mtimeMs)
|
||||
return locks[0]
|
||||
}
|
||||
|
||||
@@ -284,14 +314,22 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function scoreEditorLock(lock: EditorLockFile, cwd: string) {
|
||||
const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
|
||||
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))
|
||||
function pathContainsLength(parent: string, child: string) {
|
||||
const resolved = path.resolve(parent)
|
||||
const relative = path.relative(resolved, path.resolve(child))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
|
||||
}
|
||||
|
||||
function openEditorSocket(connection: EditorConnection) {
|
||||
@@ -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,6 +1,8 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
|
||||
export const TuiEvent = {
|
||||
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
|
||||
@@ -36,7 +38,9 @@ export const TuiEvent = {
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
||||
duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }),
|
||||
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
description: "Duration in milliseconds",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
SessionSelect: BusEvent.define(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useConnected } from "../../component/dialog-model"
|
||||
import { useConnected } from "../../component/use-connected"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "../component/border"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { type TuiEvent } from "../event"
|
||||
import { TuiEvent } from "../event"
|
||||
|
||||
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.properties>
|
||||
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
|
||||
|
||||
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
@@ -55,13 +58,13 @@ function init() {
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = {
|
||||
show(options: ToastOptions) {
|
||||
const { duration, ...currentToast } = options
|
||||
setStore("currentToast", currentToast)
|
||||
show(options: ToastInput) {
|
||||
const toastOptions = decodeToastOptions(options)
|
||||
setStore("currentToast", toastOptions)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, duration).unref()
|
||||
}, toastOptions.duration).unref()
|
||||
},
|
||||
error: (err: any) => {
|
||||
if (err instanceof Error)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * as Editor from "./editor"
|
||||
export * as Selection from "./selection"
|
||||
export * as Sound from "./sound"
|
||||
export * as Terminal from "./terminal"
|
||||
export * as Clipboard from "./clipboard"
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||
|
||||
function parse(color: string): RGBA | null {
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||
}
|
||||
if (color.startsWith("#")) {
|
||||
return RGBA.fromHex(color)
|
||||
}
|
||||
if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Query terminal colors including background, foreground, and palette (0-15).
|
||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||
*
|
||||
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
|
||||
* OSC 10/11 (foreground/background) typically work in most environments.
|
||||
*
|
||||
* Returns an object with background, foreground, and colors array.
|
||||
* Any query that fails will be null/empty.
|
||||
*/
|
||||
export async function colors(): Promise<{
|
||||
background: RGBA | null
|
||||
foreground: RGBA | null
|
||||
colors: RGBA[]
|
||||
}> {
|
||||
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let background: RGBA | null = null
|
||||
let foreground: RGBA | null = null
|
||||
const paletteColors: RGBA[] = []
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
|
||||
// Match OSC 11 (background color)
|
||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (bgMatch) {
|
||||
background = parse(bgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 10 (foreground color)
|
||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||
if (fgMatch) {
|
||||
foreground = parse(fgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 4 (palette colors)
|
||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||
for (const match of paletteMatches) {
|
||||
const index = parseInt(match[1])
|
||||
const color = parse(match[2])
|
||||
if (color) paletteColors[index] = color
|
||||
}
|
||||
|
||||
// Return immediately if we have all 16 palette colors
|
||||
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
||||
cleanup()
|
||||
resolve({ background, foreground, colors: paletteColors })
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
|
||||
// Query background (OSC 11)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
// Query foreground (OSC 10)
|
||||
process.stdout.write("\x1b]10;?\x07")
|
||||
// Query palette colors 0-15 (OSC 4)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
process.stdout.write(`\x1b]4;${i};?\x07`)
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve({ background, foreground, colors: paletteColors })
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -98,6 +98,9 @@ export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.optional(Schema.String).annotate({
|
||||
description: "Default shell to use for terminal and bash tool",
|
||||
}),
|
||||
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
|
||||
server: Schema.optional(ConfigServer.Server).annotate({
|
||||
description: "Server configuration for opencode serve and web commands",
|
||||
@@ -280,7 +283,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[]>
|
||||
@@ -310,10 +313,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
|
||||
return applyEdits(input, edits)
|
||||
}
|
||||
|
||||
return Object.entries(patch).reduce((result, [key, value]) => {
|
||||
if (value === undefined) return result
|
||||
return patchJsonc(result, value, [...path, key])
|
||||
}, input)
|
||||
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
|
||||
}
|
||||
|
||||
function writable(info: Info) {
|
||||
@@ -321,6 +321,13 @@ function writable(info: Info) {
|
||||
return next
|
||||
}
|
||||
|
||||
function writableGlobal(info: Info) {
|
||||
const next = writable(info)
|
||||
// When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key
|
||||
if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
|
||||
return next
|
||||
}
|
||||
|
||||
export const ConfigDirectoryTypoError = NamedError.create(
|
||||
"ConfigDirectoryTypoError",
|
||||
z.object({
|
||||
@@ -719,14 +726,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) {
|
||||
@@ -749,15 +756,16 @@ export const layer = Layer.effect(
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const patch = writableGlobal(config)
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
|
||||
const merged = mergeDeep(writable(existing), writable(config))
|
||||
const merged = mergeDeep(writable(existing), patch)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, writable(config))
|
||||
const updated = patchJsonc(before, patch)
|
||||
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -132,6 +132,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
||||
)
|
||||
|
||||
// Use the package manager's resolver so registries, mirrors, auth, proxies, and dist-tags match upgrade behavior.
|
||||
const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
|
||||
const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
|
||||
const result = yield* run([method, ...args])
|
||||
@@ -173,163 +174,159 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
const methodImpl = Effect.fn("Installation.method")(function* () {
|
||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
||||
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
||||
const exec = process.execPath.toLowerCase()
|
||||
|
||||
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
||||
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
||||
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
||||
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
||||
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
||||
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
||||
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
||||
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
||||
]
|
||||
|
||||
checks.sort((a, b) => {
|
||||
const aMatches = exec.includes(a.name)
|
||||
const bMatches = exec.includes(b.name)
|
||||
if (aMatches && !bMatches) return -1
|
||||
if (!aMatches && bMatches) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const check of checks) {
|
||||
const output = yield* check.command()
|
||||
const installedName =
|
||||
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
||||
if (output.includes(installedName)) {
|
||||
return check.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown" as Method
|
||||
})
|
||||
|
||||
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
||||
const detectedMethod = installMethod || (yield* methodImpl())
|
||||
|
||||
if (detectedMethod === "brew") {
|
||||
const formula = yield* getBrewFormula()
|
||||
if (formula.includes("/")) {
|
||||
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
||||
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
||||
return info.formulae[0].versions.stable
|
||||
}
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
||||
return data.versions.stable
|
||||
}
|
||||
|
||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
|
||||
}
|
||||
|
||||
if (detectedMethod === "choco") {
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
||||
return data.d.results[0].Version
|
||||
}
|
||||
|
||||
if (detectedMethod === "scoop") {
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
||||
return data.version
|
||||
}
|
||||
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
||||
return data.tag_name.replace(/^v/, "")
|
||||
}, Effect.orDie)
|
||||
|
||||
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
||||
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
||||
switch (m) {
|
||||
case "curl":
|
||||
result = yield* upgradeCurl(target)
|
||||
break
|
||||
case "npm":
|
||||
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "pnpm":
|
||||
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "bun":
|
||||
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "brew": {
|
||||
const formula = yield* getBrewFormula()
|
||||
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
||||
if (formula.includes("/")) {
|
||||
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
||||
if (tap.code !== 0) {
|
||||
result = tap
|
||||
break
|
||||
}
|
||||
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
||||
const dir = repo.trim()
|
||||
if (dir) {
|
||||
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
||||
if (pull.code !== 0) {
|
||||
result = pull
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result = yield* run(["brew", "upgrade", formula], { env })
|
||||
break
|
||||
}
|
||||
case "choco":
|
||||
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
||||
break
|
||||
case "scoop":
|
||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||
break
|
||||
default:
|
||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||
}
|
||||
if (!result || result.code !== 0) {
|
||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||
return yield* new UpgradeFailedError({ stderr })
|
||||
}
|
||||
log.info("upgraded", {
|
||||
method: m,
|
||||
target,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
yield* text([process.execPath, "--version"])
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
const result: Interface = {
|
||||
info: Effect.fn("Installation.info")(function* () {
|
||||
return {
|
||||
version: InstallationVersion,
|
||||
latest: yield* latestImpl(),
|
||||
latest: yield* result.latest(),
|
||||
}
|
||||
}),
|
||||
method: methodImpl,
|
||||
latest: latestImpl,
|
||||
upgrade: upgradeImpl,
|
||||
})
|
||||
method: Effect.fn("Installation.method")(function* () {
|
||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
||||
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
||||
const exec = process.execPath.toLowerCase()
|
||||
|
||||
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
||||
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
||||
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
||||
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
||||
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
||||
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
||||
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
||||
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
||||
]
|
||||
|
||||
checks.sort((a, b) => {
|
||||
const aMatches = exec.includes(a.name)
|
||||
const bMatches = exec.includes(b.name)
|
||||
if (aMatches && !bMatches) return -1
|
||||
if (!aMatches && bMatches) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const check of checks) {
|
||||
const output = yield* check.command()
|
||||
const installedName =
|
||||
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
||||
if (output.includes(installedName)) {
|
||||
return check.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown" as Method
|
||||
}),
|
||||
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
||||
const detectedMethod = installMethod || (yield* result.method())
|
||||
|
||||
if (detectedMethod === "brew") {
|
||||
const formula = yield* getBrewFormula()
|
||||
if (formula.includes("/")) {
|
||||
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
||||
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
||||
return info.formulae[0].versions.stable
|
||||
}
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
||||
return data.versions.stable
|
||||
}
|
||||
|
||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
|
||||
}
|
||||
|
||||
if (detectedMethod === "choco") {
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
||||
return data.d.results[0].Version
|
||||
}
|
||||
|
||||
if (detectedMethod === "scoop") {
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
||||
return data.version
|
||||
}
|
||||
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
||||
return data.tag_name.replace(/^v/, "")
|
||||
}, Effect.orDie),
|
||||
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
||||
let upgradeResult: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
||||
switch (m) {
|
||||
case "curl":
|
||||
upgradeResult = yield* upgradeCurl(target)
|
||||
break
|
||||
case "npm":
|
||||
upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "pnpm":
|
||||
upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "bun":
|
||||
upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
||||
break
|
||||
case "brew": {
|
||||
const formula = yield* getBrewFormula()
|
||||
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
||||
if (formula.includes("/")) {
|
||||
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
||||
if (tap.code !== 0) {
|
||||
upgradeResult = tap
|
||||
break
|
||||
}
|
||||
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
||||
const dir = repo.trim()
|
||||
if (dir) {
|
||||
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
||||
if (pull.code !== 0) {
|
||||
upgradeResult = pull
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
upgradeResult = yield* run(["brew", "upgrade", formula], { env })
|
||||
break
|
||||
}
|
||||
case "choco":
|
||||
upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
||||
break
|
||||
case "scoop":
|
||||
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
|
||||
break
|
||||
default:
|
||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||
}
|
||||
if (!upgradeResult || upgradeResult.code !== 0) {
|
||||
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
|
||||
return yield* new UpgradeFailedError({ stderr })
|
||||
}
|
||||
log.info("upgraded", {
|
||||
method: m,
|
||||
target,
|
||||
stdout: upgradeResult.stdout,
|
||||
stderr: upgradeResult.stderr,
|
||||
})
|
||||
yield* text([process.execPath, "--version"])
|
||||
}),
|
||||
}
|
||||
|
||||
return Service.of(result)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
@@ -612,27 +616,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) => [
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Config } from "@/config"
|
||||
import { InstanceState, EffectBridge } from "@/effect"
|
||||
import { lazy } from "@opencode-ai/core/util/lazy"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import type { Proc } from "#pty"
|
||||
import { Log } from "../util"
|
||||
import { lazy } from "@opencode-ai/core/util/lazy"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { EffectBridge } from "@/effect"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
|
||||
@@ -117,8 +117,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pt
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
function teardown(session: Active) {
|
||||
try {
|
||||
session.process.kill()
|
||||
@@ -174,8 +176,9 @@ export const layer = Layer.effect(
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const cfg = yield* config.get()
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
const command = input.command || Shell.preferred(cfg.shell)
|
||||
const args = input.args || []
|
||||
if (Shell.login(command)) {
|
||||
args.push("-l")
|
||||
@@ -360,6 +363,10 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Pty from "."
|
||||
|
||||
@@ -101,7 +101,7 @@ const app = (upgrade: UpgradeWebSocket) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const log = Log.Default.clone().tag("service", "server-proxy")
|
||||
const log = Log.create({ service: "server-proxy" })
|
||||
|
||||
export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
if (!Workspace.isSyncing(workspaceID)) {
|
||||
|
||||
@@ -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,46 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Log } from "@/util"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const EventPaths = {
|
||||
event: "/event",
|
||||
} as const
|
||||
|
||||
function eventData(data: unknown) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
export const eventRoute = HttpRouter.add(
|
||||
"GET",
|
||||
EventPaths.event,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
|
||||
)
|
||||
|
||||
log.info("event connected")
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ type: "server.connected", properties: {} }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.encodeText,
|
||||
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
|
||||
),
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.provide(Bus.layer)),
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { EffectBridge } from "@/effect"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/pty"
|
||||
const Params = Schema.Struct({
|
||||
ptyID: PtyID,
|
||||
})
|
||||
const CursorQuery = Schema.Struct({
|
||||
cursor: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const PtyPaths = {
|
||||
list: root,
|
||||
create: root,
|
||||
get: `${root}/:ptyID`,
|
||||
update: `${root}/:ptyID`,
|
||||
remove: `${root}/:ptyID`,
|
||||
connect: `${root}/:ptyID/connect`,
|
||||
} as const
|
||||
|
||||
export const PtyApi = HttpApi.make("pty")
|
||||
.add(
|
||||
HttpApiGroup.make("pty")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||
success: Schema.Array(Pty.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.list",
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
payload: Pty.CreateInput,
|
||||
success: Pty.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.create",
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||
params: { ptyID: PtyID },
|
||||
success: Pty.Info,
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.get",
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||
params: { ptyID: PtyID },
|
||||
payload: Pty.UpdateInput,
|
||||
success: Pty.Info,
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.update",
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||
params: { ptyID: PtyID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.remove",
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "pty",
|
||||
description: "Experimental HttpApi PTY routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||
return yield* pty.list()
|
||||
})
|
||||
|
||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
bridge.promise(
|
||||
pty.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const info = yield* pty.get(ctx.params.ptyID)
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
const info = yield* pty.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* pty.remove(ctx.params.ptyID)
|
||||
return true
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
|
||||
handlers
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("get", get)
|
||||
.handle("update", update)
|
||||
.handle("remove", remove),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
let closed = false
|
||||
const adapter = {
|
||||
get readyState() {
|
||||
return closed ? 3 : 1
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
if (closed) return
|
||||
Effect.runFork(
|
||||
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
handler.onClose()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}).pipe(Effect.provide(Pty.defaultLayer)),
|
||||
)
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Observability } from "@/effect"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Pty } from "@/pty"
|
||||
import { Session } from "@/session"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util"
|
||||
import { authorizationLayer } from "./auth"
|
||||
import { ConfigApi, configHandlers } from "./config"
|
||||
import { eventRoute } from "./event"
|
||||
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 { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
|
||||
import { ProviderApi, providerHandlers } from "./provider"
|
||||
import { QuestionApi, questionHandlers } from "./question"
|
||||
import { SessionApi, sessionHandlers } from "./session"
|
||||
import { SyncApi, syncHandlers } from "./sync"
|
||||
import { TuiApi, tuiHandlers } from "./tui"
|
||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
const Query = Schema.Struct({
|
||||
@@ -62,14 +72,25 @@ const instance = HttpRouter.middleware()(
|
||||
).layer
|
||||
|
||||
export const routes = Layer.mergeAll(
|
||||
eventRoute,
|
||||
ptyConnectRoute,
|
||||
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)),
|
||||
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
|
||||
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
|
||||
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(TuiApi).pipe(
|
||||
Layer.provide(tuiHandlers),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||
).pipe(
|
||||
Layer.provide(authorizationLayer),
|
||||
@@ -81,6 +102,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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { nextTuiRequest, submitTuiResponse } from "../tui"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/tui"
|
||||
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
|
||||
const TuiRequestPayload = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
}).annotate({ identifier: "TuiRequest" })
|
||||
const TuiPublishPayload = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
|
||||
]).annotate({ identifier: "TuiEventInput" })
|
||||
|
||||
const commandAliases = {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
} as const
|
||||
|
||||
export const TuiPaths = {
|
||||
appendPrompt: `${root}/append-prompt`,
|
||||
openHelp: `${root}/open-help`,
|
||||
openSessions: `${root}/open-sessions`,
|
||||
openThemes: `${root}/open-themes`,
|
||||
openModels: `${root}/open-models`,
|
||||
submitPrompt: `${root}/submit-prompt`,
|
||||
clearPrompt: `${root}/clear-prompt`,
|
||||
executeCommand: `${root}/execute-command`,
|
||||
showToast: `${root}/show-toast`,
|
||||
publish: `${root}/publish`,
|
||||
selectSession: `${root}/select-session`,
|
||||
controlNext: `${root}/control/next`,
|
||||
controlResponse: `${root}/control/response`,
|
||||
} as const
|
||||
|
||||
export const TuiApi = HttpApi.make("tui")
|
||||
.add(
|
||||
HttpApiGroup.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.appendPrompt",
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openHelp",
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openSessions",
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openThemes",
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openModels",
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.submitPrompt",
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.clearPrompt",
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
|
||||
payload: CommandPayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.executeCommand",
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.showToast",
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("publish", TuiPaths.publish, {
|
||||
payload: TuiPublishPayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.publish",
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
success: Schema.Boolean,
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.selectSession",
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.next",
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI request from the queue for processing.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
|
||||
payload: Schema.Unknown,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.response",
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const tuiHandlers = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const session = yield* Session.Service
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command })
|
||||
|
||||
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
|
||||
payload: typeof TuiEvent.PromptAppend.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
|
||||
yield* publishCommand("help.show")
|
||||
return true
|
||||
})
|
||||
|
||||
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
|
||||
yield* publishCommand("model.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
|
||||
yield* publishCommand("prompt.submit")
|
||||
return true
|
||||
})
|
||||
|
||||
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
|
||||
yield* publishCommand("prompt.clear")
|
||||
return true
|
||||
})
|
||||
|
||||
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
|
||||
return true
|
||||
})
|
||||
|
||||
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
|
||||
payload: typeof TuiEvent.ToastShow.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
|
||||
if (ctx.payload.type === TuiEvent.PromptAppend.type)
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.CommandExecute.type)
|
||||
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.SessionSelect.type)
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
|
||||
return true
|
||||
})
|
||||
|
||||
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
|
||||
payload: typeof TuiEvent.SessionSelect.properties.Type
|
||||
}) {
|
||||
yield* session
|
||||
.get(ctx.payload.sessionID)
|
||||
.pipe(Effect.catchCause(() => Effect.fail(new HttpApiError.NotFound({}))))
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
|
||||
return yield* Effect.promise(() => nextTuiRequest())
|
||||
})
|
||||
|
||||
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
|
||||
submitTuiResponse(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
|
||||
handlers
|
||||
.handle("appendPrompt", appendPrompt)
|
||||
.handle("openHelp", openHelp)
|
||||
.handle("openSessions", openSessions)
|
||||
.handle("openThemes", openThemes)
|
||||
.handle("openModels", openModels)
|
||||
.handle("submitPrompt", submitPrompt)
|
||||
.handle("clearPrompt", clearPrompt)
|
||||
.handle("executeCommand", executeCommand)
|
||||
.handle("showToast", showToast)
|
||||
.handle("publish", publish)
|
||||
.handle("selectSession", selectSession)
|
||||
.handle("controlNext", controlNext)
|
||||
.handle("controlResponse", controlResponse),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -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,15 @@ import { QuestionRoutes } from "./question"
|
||||
import { PermissionRoutes } from "./permission"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||
import { PtyPaths } from "./httpapi/pty"
|
||||
import { EventPaths } from "./httpapi/event"
|
||||
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 { TuiPaths } from "./httpapi/tui"
|
||||
import { ProjectRoutes } from "./project"
|
||||
import { SessionRoutes } from "./session"
|
||||
import { PtyRoutes } from "./pty"
|
||||
@@ -38,26 +44,106 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
|
||||
app.get("/question", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
|
||||
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(PtyPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.connect, (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))
|
||||
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
|
||||
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -201,7 +287,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 +310,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 +333,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 +379,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
description: "Formatter status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Format.Status.array()),
|
||||
schema: resolver(Format.Status.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,14 +6,41 @@ import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { NotFoundError } from "@/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const ShellItem = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
acceptable: z.boolean(),
|
||||
})
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/shells",
|
||||
describeRoute({
|
||||
summary: "List available shells",
|
||||
description: "Get a list of available shells on the system.",
|
||||
operationId: "pty.shells",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of shells",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(ShellItem)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Shell.list())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
|
||||
@@ -12,15 +12,23 @@ import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
|
||||
const TuiRequest = z.object({
|
||||
export const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
})
|
||||
|
||||
type TuiRequest = z.infer<typeof TuiRequest>
|
||||
export type TuiRequest = z.infer<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<any>()
|
||||
const response = new AsyncQueue<unknown>()
|
||||
|
||||
export function nextTuiRequest() {
|
||||
return request.next()
|
||||
}
|
||||
|
||||
export function submitTuiResponse(body: unknown) {
|
||||
response.push(body)
|
||||
}
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
@@ -50,7 +58,7 @@ const TuiControlRoutes = new Hono()
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const req = await request.next()
|
||||
const req = await nextTuiRequest()
|
||||
return c.json(req)
|
||||
},
|
||||
)
|
||||
@@ -74,7 +82,7 @@ const TuiControlRoutes = new Hono()
|
||||
validator("json", z.any()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
response.push(body)
|
||||
submitTuiResponse(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,11 +27,11 @@ 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"
|
||||
import { ConfigMarkdown } from "../config"
|
||||
import { Config, ConfigMarkdown } from "../config"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { SessionProcessor } from "./processor"
|
||||
@@ -92,6 +92,7 @@ export const layer = Layer.effect(
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const mcp = yield* MCP.Service
|
||||
@@ -783,49 +784,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
|
||||
const sh = Shell.preferred()
|
||||
const shellName = (
|
||||
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
|
||||
).toLowerCase()
|
||||
const cfg = yield* config.get()
|
||||
const sh = Shell.preferred(cfg.shell)
|
||||
const cwd = ctx.directory
|
||||
const invocations: Record<string, { args: string[] }> = {
|
||||
nu: { args: ["-c", input.command] },
|
||||
fish: { args: ["-c", input.command] },
|
||||
zsh: {
|
||||
args: [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
],
|
||||
},
|
||||
bash: {
|
||||
args: [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
],
|
||||
},
|
||||
cmd: { args: ["/c", input.command] },
|
||||
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
||||
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
||||
"": { args: ["-c", input.command] },
|
||||
}
|
||||
|
||||
const args = (invocations[shellName] ?? invocations[""]).args
|
||||
const args = Shell.args(sh, input.command, cwd)
|
||||
const shellEnv = yield* plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
@@ -842,7 +804,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
let output = ""
|
||||
let aborted = false
|
||||
|
||||
const finish = Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
if (aborted) {
|
||||
@@ -1588,7 +1549,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
const sh = Shell.preferred()
|
||||
const cfg = yield* config.get()
|
||||
const sh = Shell.preferred(cfg.shell)
|
||||
const results = yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
|
||||
@@ -1689,6 +1651,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
|
||||
@@ -7,10 +7,23 @@ import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
bash: { login: true, posix: true },
|
||||
dash: { login: true, posix: true },
|
||||
fish: { deny: true, login: true },
|
||||
ksh: { login: true, posix: true },
|
||||
nu: { deny: true },
|
||||
powershell: { ps: true },
|
||||
pwsh: { ps: true },
|
||||
sh: { login: true, posix: true },
|
||||
zsh: { login: true, posix: true },
|
||||
}
|
||||
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
||||
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
||||
export type Item = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
@@ -50,22 +63,53 @@ function full(file: string) {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
}
|
||||
if (name(shell) === "bash") return gitbash() || which(shell) || shell
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function pick() {
|
||||
const pwsh = which("pwsh.exe")
|
||||
if (pwsh) return pwsh
|
||||
const powershell = which("powershell.exe")
|
||||
if (powershell) return powershell
|
||||
function meta(file: string) {
|
||||
return META[name(file)]
|
||||
}
|
||||
|
||||
function ok(file: string) {
|
||||
return meta(file)?.deny !== true
|
||||
}
|
||||
|
||||
function rooted(file: string) {
|
||||
return path.isAbsolute(Filesystem.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string) {
|
||||
const shell = full(file)
|
||||
if (rooted(shell)) {
|
||||
if (Filesystem.stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
}
|
||||
|
||||
function win() {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map(full),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function unix() {
|
||||
const text = await Filesystem.readText("/etc/shells").catch(() => "")
|
||||
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
||||
if (process.platform === "win32") {
|
||||
const shell = pick()
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win()[0]!
|
||||
return fallback()
|
||||
}
|
||||
|
||||
@@ -79,11 +123,6 @@ export function gitbash() {
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
const file = gitbash()
|
||||
if (file) return file
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
@@ -96,15 +135,81 @@ export function name(file: string) {
|
||||
}
|
||||
|
||||
export function login(file: string) {
|
||||
return LOGIN.has(name(file))
|
||||
return meta(file)?.login === true
|
||||
}
|
||||
|
||||
export function posix(file: string) {
|
||||
return POSIX.has(name(file))
|
||||
return meta(file)?.posix === true
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => select(process.env.SHELL))
|
||||
export function ps(file: string) {
|
||||
return meta(file)?.ps === true
|
||||
}
|
||||
|
||||
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
function info(file: string): Item {
|
||||
const item = full(file)
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n) ? n : item,
|
||||
acceptable: ok(item),
|
||||
}
|
||||
}
|
||||
|
||||
export function args(file: string, command: string, cwd: string) {
|
||||
const n = name(file)
|
||||
if (n === "nu" || n === "fish") return ["-c", command]
|
||||
if (n === "zsh") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
]
|
||||
}
|
||||
if (n === "bash") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
]
|
||||
}
|
||||
if (n === "cmd") return ["/c", command]
|
||||
if (ps(file)) return ["-NoProfile", "-Command", command]
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
const defaultPreferred = lazy(() => select(process.env.SHELL))
|
||||
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
|
||||
export function preferred(configShell?: string) {
|
||||
if (configShell) return select(configShell)
|
||||
return defaultPreferred()
|
||||
}
|
||||
preferred.reset = () => defaultPreferred.reset()
|
||||
|
||||
export function acceptable(configShell?: string) {
|
||||
if (configShell) return select(configShell, { acceptable: true })
|
||||
return defaultAcceptable()
|
||||
}
|
||||
acceptable.reset = () => defaultAcceptable.reset()
|
||||
|
||||
export async function list(): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win() : await unix()
|
||||
return shells.filter((s) => resolve(s)).map(info)
|
||||
}
|
||||
|
||||
export * as Shell from "./shell"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Language, type Node } from "web-tree-sitter"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "@/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Shell } from "@/shell/shell"
|
||||
|
||||
@@ -20,10 +21,10 @@ 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
|
||||
const PS = new Set(["powershell", "pwsh"])
|
||||
const CWD = new Set(["cd", "push-location", "set-location"])
|
||||
const FILES = new Set([
|
||||
...CWD,
|
||||
@@ -277,8 +278,8 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
|
||||
})
|
||||
})
|
||||
|
||||
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
if (process.platform === "win32" && PS.has(name)) {
|
||||
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
if (process.platform === "win32" && Shell.ps(shell)) {
|
||||
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
cwd,
|
||||
env,
|
||||
@@ -295,7 +296,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
}
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||
@@ -327,6 +327,7 @@ const parser = lazy(async () => {
|
||||
export const BashTool = Tool.define(
|
||||
"bash",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const trunc = yield* Truncate.Service
|
||||
@@ -407,7 +408,6 @@ export const BashTool = Tool.define(
|
||||
const run = Effect.fn("BashTool.run")(function* (
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
@@ -437,7 +437,7 @@ export const BashTool = Tool.define(
|
||||
|
||||
const code: number | null = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
||||
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
|
||||
@@ -566,7 +566,8 @@ export const BashTool = Tool.define(
|
||||
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const shell = Shell.acceptable()
|
||||
const cfg = yield* config.get()
|
||||
const shell = Shell.acceptable(cfg.shell)
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
@@ -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)
|
||||
@@ -593,7 +595,7 @@ export const BashTool = Tool.define(
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const ps = Shell.ps(shell)
|
||||
const root = yield* parse(params.command, ps)
|
||||
const scan = yield* collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
@@ -602,7 +604,6 @@ export const BashTool = Tool.define(
|
||||
return yield* run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user