Compare commits

..

6 Commits

Author SHA1 Message Date
Dax Raad
add16af117 fix(opencode): fail on brew repo lookup errors 2026-03-06 01:33:59 -05:00
Dax Raad
054f848307 fix(opencode): honor nothrow on spawn errors 2026-03-06 01:30:12 -05:00
Dax Raad
3b2e3afebd fix(opencode): address migration review feedback 2026-03-06 01:25:04 -05:00
Dax Raad
f1c7d4cefb Merge remote-tracking branch 'origin/dev' into chore/remove-bun-shell-opencode
# Conflicts:
#	packages/opencode/src/file/index.ts
#	packages/opencode/src/worktree/index.ts
2026-03-06 01:15:26 -05:00
Dax Raad
1454dd1dc1 refactor(opencode): remove remaining Bun shell usage 2026-03-06 01:09:00 -05:00
Dax Raad
5d68b1b148 refactor(opencode): replace Bun shell in core flows 2026-03-06 00:54:11 -05:00
79 changed files with 881 additions and 953 deletions

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -1,141 +0,0 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Cài đặt
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Các trình quản lý gói (Package managers)
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
sudo pacman -S opencode # Arch Linux (Bản ổn định)
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
mise use -g opencode # Mọi hệ điều hành
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
```
> [!TIP]
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
### Ứng dụng Desktop (BETA)
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
| Nền tảng | Tải xuống |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, hoặc AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Thư mục cài đặt
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
4. `$HOME/.opencode/bin` - Mặc định dự phòng
```bash
# Ví dụ
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents (Đại diện)
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
- Mặc định từ chối việc chỉnh sửa tệp
- Hỏi quyền trước khi chạy các lệnh bash
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
### Tài liệu
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
### Đóng góp
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
### Xây dựng trên nền tảng OpenCode
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.20",
"version": "1.2.19",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -429,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.20",
"version": "1.2.19",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -475,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -521,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"zod": "catalog:",
},
@@ -532,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.20",
"version": "1.2.19",
"description": "",
"type": "module",
"exports": {

View File

@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
pendingAutoAccept: boolean
}>({
popover: null,
historyIndex: -1,
@@ -252,6 +253,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
@@ -304,6 +306,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
createEffect(
on(sessionKey, () => {
setStore("pendingAutoAccept", false)
}),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
@@ -953,7 +961,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
if (!id) return store.pendingAutoAccept
return permission.isAutoAccepting(id, sdk.directory)
})
@@ -1328,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
setStore("pendingAutoAccept", (value) => !value)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)

View File

@@ -146,7 +146,6 @@ const DICT: Record<Locale, Dictionary> = {
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
@@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
import { autoRespondsPermission } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
})

View File

@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
return `${base64Encode(directory)}/${sessionID}`
}
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
return autoAccept[key] ?? autoAccept[sessionID]
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
} from "./permission-auto-respond"
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return shouldAutoRespond(permission, directory)
},
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
disable(sessionID, directory)
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
}
},
})

View File

@@ -10,8 +10,9 @@ import {
ParentProps,
Show,
untrack,
type JSX,
} from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -19,6 +20,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -1843,7 +1846,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project}>
<Show when={panelProps.project} keyed>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -1852,7 +1855,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
onSave={(next) => renameProject(p, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
@@ -1861,7 +1864,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
value={p.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -1869,7 +1872,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
{p.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -1880,7 +1883,7 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
@@ -1889,24 +1892,24 @@ export default function Layout(props: ParentProps) {
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => toggleProjectWorkspaces(p)}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
{layout.sidebar.workspaces(p.worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
@@ -1917,8 +1920,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -1938,7 +1941,7 @@ export default function Layout(props: ParentProps) {
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
@@ -1946,7 +1949,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p()}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -1956,7 +1959,7 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</div>
@@ -1981,7 +1984,7 @@ export default function Layout(props: ParentProps) {
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2093,9 +2096,11 @@ export default function Layout(props: ParentProps) {
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
</div>
{(worktree) => (
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
</div>
)}
</Show>
<Show when={layout.sidebar.opened()}>
<ResizeHandle

View File

@@ -611,7 +611,7 @@ export function MessageTimeline(props: {
</Show>
</Show>
</div>
<Show when={sessionID()}>
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
@@ -646,12 +646,12 @@ export function MessageTimeline(props: {
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>

View File

@@ -331,7 +331,9 @@ export function SessionSidePanel(props: {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
<Show when={path()} keyed>
{(p) => <FileVisual active path={p} />}
</Show>
</div>
)
}}

View File

@@ -191,8 +191,8 @@ export function TerminalPanel() {
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
<Show when={byId().get(id)} keyed>
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
</Show>
)}
</For>
@@ -217,10 +217,10 @@ export function TerminalPanel() {
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
<Show when={byId().get(id)} keyed>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
</div>
)}
</Show>
@@ -229,14 +229,14 @@ export function TerminalPanel() {
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
<Show when={store.activeDraggable} keyed>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
<Show when={byId().get(draggedId)} keyed>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
title: t.title,
titleNumber: t.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>

View File

@@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
])
const isAutoAcceptActive = () => {
const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
const permissionCommands = createMemo(() => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
if (sessionID) {
permission.toggleAutoAccept(sessionID, sdk.directory)
} else {
permission.toggleAutoAcceptDirectory(sdk.directory)
}
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: active
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.20",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.20",
"version": "1.2.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.20",
"version": "1.2.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -76,7 +76,6 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("en")) return "en"
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -77,7 +77,6 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("en")) return "en"
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.20",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,8 +1,10 @@
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import { Identifier } from "@opencode-ai/util/identifier"
import z from "zod"
import { Storage } from "./storage"
import { Binary } from "@opencode-ai/util/binary"
export namespace Share {
export const Info = z.object({
@@ -36,81 +38,6 @@ export namespace Share {
])
export type Data = z.infer<typeof Data>
type Snapshot = {
data: Data[]
}
type Compaction = {
event?: string
data: Data[]
}
function key(item: Data) {
switch (item.type) {
case "session":
return "session"
case "message":
return `message/${item.data.id}`
case "part":
return `part/${item.data.messageID}/${item.data.id}`
case "session_diff":
return "session_diff"
case "model":
return "model"
}
}
function merge(...items: Data[][]) {
const map = new Map<string, Data>()
for (const list of items) {
for (const item of list) {
map.set(key(item), item)
}
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, item]) => item)
}
async function readSnapshot(shareID: string) {
return (await Storage.read<Snapshot>(["share_snapshot", shareID]))?.data
}
async function writeSnapshot(shareID: string, data: Data[]) {
await Storage.write<Snapshot>(["share_snapshot", shareID], { data })
}
async function legacy(shareID: string) {
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
data: [],
event: undefined,
}
const list = await Storage.list({
prefix: ["share_event", shareID],
before: compaction.event,
}).then((x) => x.toReversed())
if (list.length === 0) {
if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data)
return compaction.data
}
const next = merge(
compaction.data,
await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) =>
x.flatMap((item) => item ?? []),
),
)
await Promise.all([
Storage.write(["share_compaction", shareID], {
event: list.at(-1)?.at(-1),
data: next,
}),
writeSnapshot(shareID, next),
])
return next
}
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
const info: Info = {
@@ -120,7 +47,7 @@ export namespace Share {
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
await Storage.write(["share", info.id], info)
return info
})
@@ -133,13 +60,8 @@ export namespace Share {
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
const groups = await Promise.all([
Storage.list({ prefix: ["share_snapshot", body.id] }),
Storage.list({ prefix: ["share_compaction", body.id] }),
Storage.list({ prefix: ["share_event", body.id] }),
Storage.list({ prefix: ["share_data", body.id] }),
])
for (const item of groups.flat()) {
const list = await Storage.list({ prefix: ["share_data", body.id] })
for (const item of list) {
await Storage.remove(item)
}
})
@@ -153,13 +75,59 @@ export namespace Share {
const share = await get(input.share.id)
if (!share) throw new Errors.NotFound(input.share.id)
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
await writeSnapshot(input.share.id, merge(data, input.data))
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
},
)
type Compaction = {
event?: string
data: Data[]
}
export async function data(shareID: string) {
return (await readSnapshot(shareID)) ?? legacy(shareID)
console.log("reading compaction")
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
data: [],
event: undefined,
}
console.log("reading pending events")
const list = await Storage.list({
prefix: ["share_event", shareID],
before: compaction.event,
}).then((x) => x.toReversed())
console.log("compacting", list.length)
if (list.length > 0) {
const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat())
for (const item of data) {
if (!item) continue
const key = (item: Data) => {
switch (item.type) {
case "session":
return "session"
case "message":
return `message/${item.data.id}`
case "part":
return `${item.data.messageID}/${item.data.id}`
case "session_diff":
return "session_diff"
case "model":
return "model"
}
}
const id = key(item)
const result = Binary.search(compaction.data, id, key)
if (result.found) {
compaction.data[result.index] = item
} else {
compaction.data.splice(result.index, 0, item)
}
}
compaction.event = list.at(-1)?.at(-1)
await Storage.write(["share_compaction", shareID], compaction)
}
return compaction.data
}
export const syncOld = fn(

View File

@@ -108,7 +108,6 @@ app
validator("param", z.object({ shareID: z.string() })),
async (c) => {
const { shareID } = c.req.valid("param")
c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400")
return c.json(await Share.data(shareID))
},
)

View File

@@ -5,11 +5,12 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
@@ -19,11 +20,11 @@ import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { FileSSR } from "@opencode-ai/ui/file-ssr"
import { clientOnly } from "@solidjs/start"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
import { getRequestEvent } from "solid-js/web"
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
@@ -53,6 +54,12 @@ const getData = query(async (shareID) => {
session_diff: {
[sessionID: string]: FileDiff[]
}
session_diff_preload: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
session_diff_preload_split: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
}
session_status: {
[sessionID: string]: SessionStatus
}
@@ -72,6 +79,12 @@ const getData = query(async (shareID) => {
session_diff: {
[share.sessionID]: [],
},
session_diff_preload: {
[share.sessionID]: [],
},
session_diff_preload_split: {
[share.sessionID]: [],
},
session_status: {
[share.sessionID]: {
type: "idle",
@@ -88,6 +101,28 @@ const getData = query(async (shareID) => {
break
case "session_diff":
result.session_diff[share.sessionID] = item.data
await Promise.all([
Promise.all(
item.data.map(async (diff) =>
preloadMultiFileDiff<any>({
oldFile: { name: diff.file, contents: diff.before },
newFile: { name: diff.file, contents: diff.after },
options: createDefaultOptions("unified"),
// annotations,
}),
),
).then((r) => (result.session_diff_preload[share.sessionID] = r)),
Promise.all(
item.data.map(async (diff) =>
preloadMultiFileDiff<any>({
oldFile: { name: diff.file, contents: diff.before },
newFile: { name: diff.file, contents: diff.after },
options: createDefaultOptions("split"),
// annotations,
}),
),
).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
])
break
case "message":
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
@@ -108,15 +143,17 @@ const getData = query(async (shareID) => {
}, "getShareData")
export default function () {
getRequestEvent()?.response.headers.set(
"Cache-Control",
"public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
)
const params = useParams()
const data = createAsync(async () => {
if (!params.shareID) throw new Error("Missing shareID")
return getData(params.shareID)
const now = Date.now()
const data = getData(params.shareID)
console.log("getData", Date.now() - now)
return data
})
createEffect(() => {
console.log(data())
})
return (
@@ -204,8 +241,22 @@ export default function () {
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
@@ -329,9 +380,18 @@ export default function () {
<Show when={diffs().length > 0}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
diffStyle={diffStyle()}
onDiffStyleChange={setDiffStyle}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
@@ -359,7 +419,11 @@ export default function () {
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}

View File

@@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
data,
})
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
expect(snapshot?.data).toHaveLength(1)
const events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(1)
await Share.remove({ id: share.id, secret: share.secret })
})
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
data: data2,
})
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
expect(snapshot?.data).toHaveLength(2)
const events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(2)
await Share.remove({ id: share.id, secret: share.secret })
})
@@ -194,28 +194,6 @@ describe.concurrent("core.share", () => {
await Share.remove({ id: share.id, secret: share.secret })
})
test("should migrate legacy event data into the snapshot", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{
type: "part",
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
},
]
await Storage.remove(["share_snapshot", share.id])
await Storage.write(["share_event", share.id, Identifier.descending()], data)
const result = await Share.data(share.id)
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
expect(result).toHaveLength(1)
expect(snapshot?.data).toHaveLength(1)
await Share.remove({ id: share.id, secret: share.secret })
})
test("should throw error for invalid secret", async () => {
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.20"
version = "1.2.19"
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.2.20/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/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.2.20/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/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.2.20/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.20",
"version": "1.2.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.20",
"version": "1.2.19",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import solidPlugin from "@opentui/solid/bun-plugin"
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -161,9 +161,7 @@ for (const item of targets) {
console.log(`building ${name}`)
await $`mkdir -p dist/${name}/bin`
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
const workerPath = "./src/cli/cmd/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS

View File

@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
? "pr_review"
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try {
if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof $.ShellError) {
if (e instanceof Process.RunFailedError) {
msg = e.stderr.toString()
} else if (e instanceof Error) {
msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
const ret = await gitStatus(["config", "--local", "--get", config])
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
await gitRun(["config", "--local", "--unset-all", config])
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"`
await gitRun(["config", "--local", config, gitConfig])
}
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
await gitRun(["checkout", "-b", branch])
return branch
}
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20)
await $`git fetch origin --depth=${depth} ${branch}`
await $`git checkout ${branch}`
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await gitRun(["checkout", branch])
}
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
return localBranch
}
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await $`git add .`
await gitRun(["add", "."])
if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
await commitChanges(summary)
} else {
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await commitChanges(summary, actor)
}
}
await $`git push -u origin ${branch}`
await gitRun(["push", "-u", "origin", branch])
}
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push`
await gitRun(["push"])
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const remoteBranch = pr.headRefName
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push fork HEAD:${remoteBranch}`
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
}
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const ret = await gitStatus(["status", "--porcelain"])
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
return {
dirty: head !== originalHead,
uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}

View File

@@ -1,7 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { $ } from "bun"
import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({
command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult =
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.exitCode === 0) {
const prInfoText = prInfoResult.text()
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await $`git remote`.nothrow().text()).trim()
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
// Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
if (importResult.exitCode === 0) {
const importOutput = importResult.text().trim()
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {

View File

@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")

View File

@@ -3,6 +3,7 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -53,7 +54,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
if (!value) return piped
if (!piped) return value
return piped + "\n" + value

View File

@@ -1,9 +1,9 @@
import { $ } from "bun"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
@@ -34,23 +34,38 @@ export namespace Clipboard {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
@@ -58,13 +73,15 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
@@ -81,7 +98,7 @@ export namespace Clipboard {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}

View File

@@ -3,11 +3,11 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
interface UninstallArgs {
keepConfig: boolean
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
if (method === "choco" && text.includes("not running from an elevated command shell")) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
export namespace File {
const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changedFiles: Info[] = []
@@ -439,12 +439,14 @@ export namespace File {
}
}
const untrackedOutput =
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const untrackedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -465,12 +467,14 @@ export namespace File {
}
// Get deleted files
const deletedOutput =
await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const deletedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -541,16 +545,14 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) {
diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
).text()
}
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,

View File

@@ -5,7 +5,7 @@ import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
@@ -338,7 +338,7 @@ export namespace Ripgrep {
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
@@ -354,14 +354,16 @@ export namespace Ripgrep {
args.push("--")
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines

View File

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -88,13 +88,10 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const vcsDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
.catch(() => undefined)
const result = await git(["rev-parse", "--git-dir"], {
cwd: Instance.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

View File

@@ -1,11 +1,12 @@
import { BusEvent } from "@/bus/bus-event"
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global {
const OPENCODE_VERSION: string
@@ -15,6 +16,38 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
@@ -65,31 +98,31 @@ export namespace Installation {
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["npm", "list", "-g", "--depth=0"]),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).quiet().text(),
command: () => text(["yarn", "global", "list"]),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
@@ -121,61 +154,70 @@ export namespace Installation {
)
async function getBrewFormula() {
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function upgrade(method: Method, target: string) {
let cmd
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
result = await upgradeCurl(target)
break
case "npm":
cmd = $`npm install -g opencode-ai@${target}`
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
cmd = $`pnpm install -g opencode-ai@${target}`
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
cmd = $`bun install -g opencode-ai@${target}`
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
if (formula.includes("/")) {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
})
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
cmd = $`scoop install opencode@${target}`
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
@@ -186,7 +228,7 @@ export namespace Installation {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
await Process.text([process.execPath, "--version"], { nothrow: true })
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -199,7 +241,7 @@ export namespace Installation {
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -215,7 +257,7 @@ export namespace Installation {
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})

View File

@@ -4,7 +4,6 @@ import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -21,6 +20,8 @@ export namespace LSPServer {
.stat(p)
.then(() => true)
.catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -205,8 +206,8 @@ export namespace LSPServer {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -602,10 +603,11 @@ export namespace LSPServer {
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
const env = { MIX_ENV: "prod", ...process.env }
await Process.run(["mix", "deps.get"], { cwd, env })
await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
@@ -706,7 +708,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -719,7 +721,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed zls`, { bin })
@@ -831,11 +833,11 @@ export namespace LSPServer {
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
if (lspLoc.exitCode !== 0) return
if (lspLoc.code !== 0) return
const bin = lspLoc.text().trim()
const bin = lspLoc.text.trim()
return {
process: spawn(bin, {
@@ -1010,7 +1012,7 @@ export namespace LSPServer {
if (!ok) return
}
if (tar) {
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
}
await fs.rm(archive, { force: true })
@@ -1021,7 +1023,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1138,13 +1140,10 @@ export namespace LSPServer {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
}
const javaMajorVersion = await $`java -version`
.quiet()
.nothrow()
.then(({ stderr }) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return
@@ -1161,27 +1160,27 @@ export namespace LSPServer {
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
return
}
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)
.quiet()
.nothrow()
.then(({ stdout }) => stdout.toString().trim())
const jarFileName =
(await fs.readdir(launcherDir).catch(() => []))
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
?.trim() ?? ""
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1294,7 +1293,15 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const archivePath = path.join(distPath, "kotlin-ls.zip")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return
}
await Filesystem.writeStream(archivePath, download.body)
const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true)
.catch((error) => {
@@ -1304,7 +1311,7 @@ export namespace LSPServer {
if (!ok) return
await fs.rm(archivePath, { force: true })
if (process.platform !== "win32") {
await $`chmod +x ${launcherScript}`.quiet().nothrow()
await fs.chmod(launcherScript, 0o755).catch(() => {})
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
@@ -1468,10 +1475,9 @@ export namespace LSPServer {
})
if (!ok) return
} else {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
.quiet()
.then(() => true)
.catch((error) => {
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.then((result) => result.code === 0)
.catch((error: unknown) => {
log.error("Failed to extract lua-language-server archive", { error })
return false
})
@@ -1489,11 +1495,15 @@ export namespace LSPServer {
}
if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
const ok = await fs
.chmod(bin, 0o755)
.then(() => true)
.catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false
})
})
if (!ok) return
}
@@ -1707,7 +1717,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed terraform-ls`, { bin })
@@ -1790,7 +1800,7 @@ export namespace LSPServer {
if (!ok) return
}
if (ext === "tar.gz") {
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -1803,7 +1813,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed texlab", { bin })
@@ -1995,7 +2005,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -2008,7 +2018,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed tinymist", { bin })

View File

@@ -1,11 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
const log = Log.create({ service: "vcs" })
@@ -29,13 +29,13 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: Instance.worktree,
})
if (result.exitCode !== 0) return
const text = result.text().trim()
if (!text) return
return text
}
const state = Instance.state(

View File

@@ -1,5 +1,6 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { ulid } from "ulid"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
@@ -121,35 +122,20 @@ export namespace ShareNext {
data: SDK.Model[]
}
function key(item: Data) {
switch (item.type) {
case "session":
return "session"
case "message":
return `message/${item.data.id}`
case "part":
return `part/${item.data.messageID}/${item.data.id}`
case "session_diff":
return "session_diff"
case "model":
return "model"
}
}
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
async function sync(sessionID: string, data: Data[]) {
if (disabled) return
const existing = queue.get(sessionID)
if (existing) {
for (const item of data) {
existing.data.set(key(item), item)
existing.data.set("id" in item ? (item.id as string) : ulid(), item)
}
return
}
const dataMap = new Map<string, Data>()
for (const item of data) {
dataMap.set(key(item), item)
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
}
const timeout = setTimeout(async () => {
@@ -196,14 +182,10 @@ export namespace ShareNext {
const diffs = await Session.diff(sessionID)
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
const models = await Promise.all(
Array.from(
new Map(
messages
.filter((m) => m.info.role === "user")
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
).values(),
).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)),
messages
.filter((m) => m.info.role === "user")
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
)
await sync(sessionID, [
{

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -9,12 +8,17 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
@@ -34,13 +38,13 @@ export namespace Snapshot {
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
@@ -55,27 +59,27 @@ export namespace Snapshot {
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
})
.quiet()
.nothrow()
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
@@ -89,19 +93,32 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.exitCode !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text()
const files = result.text
return {
hash,
files: files
@@ -116,20 +133,37 @@ export namespace Snapshot {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
@@ -139,19 +173,37 @@ export namespace Snapshot {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
@@ -168,23 +220,36 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text().trim()
return result.text.trim()
}
export const FileDiff = z
@@ -205,12 +270,24 @@ export namespace Snapshot {
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
@@ -220,26 +297,57 @@ export namespace Snapshot {
status.set(file, kind)
}
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.lines()) {
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
@@ -261,10 +369,22 @@ export namespace Snapshot {
async function add(git: string) {
await syncExclude(git)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
}
async function syncExclude(git: string) {
@@ -281,11 +401,10 @@ export namespace Snapshot {
}
async function excludes() {
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
.quiet()
.cwd(Instance.worktree)
.nothrow()
.text()
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())

View File

@@ -5,10 +5,10 @@ import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -49,18 +49,15 @@ export namespace Storage {
}
if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
const result = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
if (!id) continue
projectID = id

View File

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import fs from "fs/promises"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =

View File

@@ -1,5 +1,5 @@
import { $ } from "bun"
import path from "path"
import { Process } from "./process"
export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) {
@@ -8,9 +8,10 @@ export namespace Archive {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
} else {
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
}

View File

@@ -25,6 +25,10 @@ export namespace Process {
stderr: Buffer
}
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
@@ -114,13 +118,33 @@ export namespace Process {
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = {
code,
stdout,
stderr,
}
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
}

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import z from "zod"
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -248,14 +249,14 @@ export namespace Worktree {
}
async function sweep(root: string) {
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
const entries = failed(first)
if (!entries.length) return first
await prune(root, entries)
return $`git clean -ffdx`.quiet().nothrow().cwd(root)
return git(["clean", "-ffdx"], { cwd: root })
}
async function canonical(input: string) {
@@ -274,7 +275,9 @@ export namespace Worktree {
if (await exists(directory)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
})
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory })
@@ -285,9 +288,9 @@ export namespace Worktree {
async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") {
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
}
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
}
type StartKind = "project" | "worktree"
@@ -297,7 +300,7 @@ export namespace Worktree {
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.exitCode === 0) return true
if (ran.code === 0) return true
log.error("worktree start command failed", {
kind,
@@ -344,10 +347,9 @@ export namespace Worktree {
}
export async function createFromInfo(info: Info, startCommand?: string) {
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
cwd: Instance.worktree,
})
if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
}
@@ -359,7 +361,7 @@ export namespace Worktree {
return () => {
const start = async () => {
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
const populated = await git(["reset", "--hard"], { cwd: info.directory })
if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message })
@@ -476,10 +478,10 @@ export namespace Worktree {
const stop = async (target: string) => {
if (!(await exists(target))) return
await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -496,9 +498,11 @@ export namespace Worktree {
}
await stop(entry.path)
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
if (removed.exitCode !== 0) {
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
@@ -515,7 +519,7 @@ export namespace Worktree {
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
@@ -535,7 +539,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -568,7 +572,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
@@ -587,18 +591,19 @@ export namespace Worktree {
: ""
const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: Instance.worktree,
})
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
cwd: Instance.worktree,
})
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
@@ -607,7 +612,7 @@ export namespace Worktree {
}
if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
@@ -619,7 +624,7 @@ export namespace Worktree {
const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
@@ -629,22 +634,26 @@ export namespace Worktree {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
cwd: worktreePath,
})
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
cwd: worktreePath,
})
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.20",
"version": "1.2.19",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -44,19 +44,6 @@ function sanitize(html: string) {
return DOMPurify.sanitize(html, config)
}
function escape(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}
type CopyLabels = {
copy: string
copied: string
@@ -250,7 +237,7 @@ export function Markdown(
const [html] = createResource(
() => local.text,
async (markdown) => {
if (isServer) return fallback(markdown)
if (isServer) return ""
const hash = checksum(markdown)
const key = local.cacheKey ?? hash
@@ -268,7 +255,7 @@ export function Markdown(
if (key && hash) touch(key, { hash, html: safe })
return safe
},
{ initialValue: isServer ? fallback(local.text) : "" },
{ initialValue: "" },
)
let copySetupTimer: ReturnType<typeof setTimeout> | undefined

View File

@@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const searchHandles = new Map<string, FileSearchHandle>()
const readyFiles = new Set<string>()
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
open: [],
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
force: {},
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.20",
"version": "1.2.19",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.20",
"version": "1.2.19",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.20",
"version": "1.2.19",
"publisher": "sst-dev",
"repository": {
"type": "git",