Compare commits

..

47 Commits

Author SHA1 Message Date
Adam
bb4537bc61 chore: fix tests 2026-03-06 15:28:13 -06:00
Adam
038f576d01 chore: default to oc-2 2026-03-06 15:28:13 -06:00
Adam
1dd5a58ba3 wip(app): colors 2026-03-06 15:28:13 -06:00
Adam
5378d9bc98 wip(app): colors 2026-03-06 15:28:13 -06:00
Adam
b4db027a1e wip(app): generate color palettes 2026-03-06 15:28:13 -06:00
Adam
7a071eff5c chore: fix test 2026-03-06 15:27:49 -06:00
opencode-agent[bot]
7da24ebf5d chore: generate 2026-03-06 19:40:36 +00:00
Shoubhit Dash
d6e0f47361 feat: add project git init api (#16383) 2026-03-06 13:39:50 -06:00
Alexandre Reyes Martins
95385eb652 fix(app): enable Safari autocorrect in normal mode, disable in shell mode (#15563) 2026-03-06 22:36:27 +05:30
Adam
a71b11caca fix(app): stale keyed show errors 2026-03-06 11:03:37 -06:00
Hoshiumi Arata
e9568999c3 fix(ui): prevent unwanted key events during composition in LineCommentEditor (#16361) 2026-03-06 11:01:13 -06:00
Dax Raad
5e699c9426 chore(storage): update drizzle and channel db handling 2026-03-06 10:58:19 -05:00
Adam
e0ca52ed1f fix(app): part type gate 2026-03-06 09:56:02 -06:00
Shoubhit Dash
1d9dcd2a27 share: speed up share loads (#16165) 2026-03-06 06:49:15 -06:00
Adam
eeeb21ff86 Revert "fix(app): stale read error"
This reverts commit 152df2428d.
2026-03-06 05:52:48 -06:00
Adam
2094e8b255 Revert "fix(app): stale keyed show errors"
This reverts commit 7665b8e30d.
2026-03-06 05:52:47 -06:00
opencode-agent[bot]
e1cf761d29 chore: generate 2026-03-06 11:48:31 +00:00
Kirill Tregubov
f64bb91257 fix(app): add english to locale matchers (#16280) 2026-03-06 05:47:39 -06:00
D
eb9eb5e334 feat: Add Vietnamese README and update all language navigation links … (#16322) 2026-03-06 05:46:53 -06:00
opencode-agent[bot]
d4d1292a0e chore: generate 2026-03-06 10:50:00 +00:00
Luis Felipe Cordeiro Sena
b7605add58 fix(app): enable auto-accept keybind regardless of permission config (#16259) 2026-03-06 04:49:12 -06:00
opencode
6c7d968c44 release: v1.2.20 2026-03-06 07:29:34 +00:00
Dax
326c70184d fix: restore Bun stdin reads for prompt input (#16300) 2026-03-06 02:08:10 -05:00
Luke Parker
aec6ca71fa fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249) 2026-03-06 15:42:08 +10:00
opencode-agent[bot]
c04da45be5 chore: update nix node_modules hashes 2026-03-06 05:31:01 +00:00
Dax
74effa8eec refactor(opencode): replace Bun.which with npm which (#15012) 2026-03-06 05:18:29 +00:00
opencode
cb411248bf release: v1.2.19 2026-03-06 04:29:10 +00:00
Sadık
46d7d2fdc0 feat: add "gpt-5.4" to codex allowed models list (#16274) 2026-03-05 23:03:01 -05:00
Dax Raad
d68afcaa55 refactor: replace Bun.stderr and Bun.color with Node.js equivalents 2026-03-05 22:20:16 -05:00
Dax Raad
bf35a865ba refactor: replace Bun.connect with net.createConnection 2026-03-05 22:17:08 -05:00
Dax Raad
6733a5a822 fix: use sha1 for hash instead of unsupported xxhash3-xxh64 2026-03-05 22:12:10 -05:00
Dax Raad
7e28098365 refactor: use node:stream/consumers for stdin reading 2026-03-05 22:08:50 -05:00
Dax Raad
ae5c9ed3dd refactor: replace Bun.stdin.text with Node.js stream reading 2026-03-05 22:04:20 -05:00
Dax Raad
a9bf1c0505 refactor: replace Bun.hash with Hash.fast using xxhash3-xxh64 2026-03-05 22:03:24 -05:00
Dax Raad
dad248832d refactor: replace Bun.write with Filesystem.write in config files 2026-03-05 21:59:20 -05:00
Dax Raad
6e89d3e597 refactor: replace Bun.write/file with Filesystem utilities in snapshot 2026-03-05 21:56:41 -05:00
Dax
3ebba02d04 refactor: replace Bun.sleep with node timers (#15013) 2026-03-05 21:54:06 -05:00
Filip
cf425d114e fix(app): stale show (#16236) 2026-03-05 18:23:48 -06:00
David Hill
39691e5174 tui: remove keyboard shortcut tooltips from new session and new workspace buttons in the sidebar 2026-03-05 23:44:00 +00:00
Frank
adaee66364 zen: gpt 5.4 pro 2026-03-05 18:39:17 -05:00
Frank
a6978167ae ci: fix 2026-03-05 15:46:17 -05:00
Frank
80c36c788c zen: gpt5.3 codex spark 2026-03-05 15:40:51 -05:00
Jun
76cdc668e8 fix(console): follow-up for #13108 docs/en routing and locale cookie sync (#13608) 2026-03-05 14:31:38 -06:00
Adam
2ba1ecabc9 fix(app): load tab on open file 2026-03-05 13:40:25 -06:00
opencode-agent[bot]
e3b6d84b57 docs(i18n): sync locale docs from english changes 2026-03-05 19:01:10 +00:00
Frank
0638e49b02 zen: gpt5.4 2026-03-05 19:01:10 +00:00
opencode
2c58964a6b release: v1.2.18 2026-03-05 19:01:03 +00:00
171 changed files with 3450 additions and 4640 deletions

View File

@@ -35,7 +35,8 @@
<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.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)

View File

@@ -35,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -35,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -35,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -35,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -35,7 +35,8 @@
<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.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)

141
README.vi.md Normal file
View File

@@ -0,0 +1,141 @@
<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,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -27,6 +27,7 @@
<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> |
@@ -34,7 +35,8 @@
<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.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)

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.17",
"version": "1.2.20",
"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.17",
"version": "1.2.20",
"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.17",
"version": "1.2.20",
"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.17",
"version": "1.2.20",
"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.17",
"version": "1.2.20",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.17",
"version": "1.2.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -351,7 +351,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -373,6 +373,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -395,10 +396,11 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -407,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -427,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.17",
"version": "1.2.20",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -438,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -473,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -519,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"zod": "catalog:",
},
@@ -530,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.17",
"version": "1.2.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -599,8 +601,8 @@
"ai": "5.0.124",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -2120,6 +2122,8 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -2680,9 +2684,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.12-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3236,7 +3240,7 @@
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -4586,7 +4590,7 @@
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -5202,6 +5206,8 @@
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -5264,6 +5270,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -5388,6 +5396,8 @@
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
@@ -5918,6 +5928,8 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -6000,6 +6012,8 @@
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

View File

@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
}
}

View File

@@ -41,8 +41,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -197,6 +197,7 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
@@ -207,7 +208,10 @@ export async function createTestProject() {
}
export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function sessionIDFromUrl(url: string) {
@@ -441,6 +445,57 @@ export async function seedSessionPermission(
return { id: result.id }
}
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {

View File

@@ -53,16 +53,26 @@ test("closing active project navigates to another open project", async ({ page,
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)

View File

@@ -0,0 +1,37 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})

View File

@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
expect(firstTheme).toBeTruthy()
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.nth(1).click()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe("oc-1")
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")

View File

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

View File

@@ -1,6 +1,5 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var themeId = localStorage.getItem("opencode-theme-id") || "oc-2"
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
@@ -9,9 +8,9 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
var css = localStorage.getItem("opencode-theme-css-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"

View File

@@ -244,7 +244,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
pendingAutoAccept: boolean
}>({
popover: null,
historyIndex: -1,
@@ -253,7 +252,6 @@ 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 })
@@ -306,12 +304,6 @@ 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) => {
@@ -961,7 +953,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 store.pendingAutoAccept
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
})
@@ -1211,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
@@ -1336,7 +1328,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => {
if (!params.id) {
setStore("pendingAutoAccept", (value) => !value)
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)

View File

@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background

View File

@@ -146,6 +146,7 @@ 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") },
@@ -217,6 +218,7 @@ 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 } from "./permission-auto-respond"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -60,4 +60,43 @@ 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,9 +5,19 @@ 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)
return autoAccept[key] ?? autoAccept[sessionID]
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
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {

View File

@@ -1,4 +1,4 @@
import { createMemo, onCleanup } from "solid-js"
import { createEffect, 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,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
} from "./permission-auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
@@ -76,6 +81,25 @@ 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>()
@@ -119,6 +143,10 @@ 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)
@@ -142,6 +170,36 @@ 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)
@@ -185,6 +243,7 @@ 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)
@@ -193,6 +252,13 @@ 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)
@@ -201,6 +267,11 @@ 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,9 +10,8 @@ import {
ParentProps,
Show,
untrack,
type JSX,
} from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -20,9 +19,8 @@ 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, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
@@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -1846,7 +1843,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project} keyed>
<Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -1855,7 +1852,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
@@ -1864,7 +1861,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",
@@ -1872,7 +1869,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>
@@ -1883,7 +1880,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,
@@ -1892,24 +1889,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}
>
@@ -1920,8 +1917,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>
@@ -1937,25 +1934,19 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -1965,15 +1956,9 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
@@ -1996,7 +1981,7 @@ export default function Layout(props: ParentProps) {
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2108,11 +2093,9 @@ export default function Layout(props: ParentProps) {
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
{(worktree) => (
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
</div>
)}
<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

@@ -720,6 +720,7 @@ export default function Page() {
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})

View File

@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
})
})

View File

@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const openTab = () => input.openTab(input.tabForPath(path))
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
else open()
})
}
}

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
@@ -611,7 +611,7 @@ export function MessageTimeline(props: {
</Show>
</Show>
</div>
<Show when={sessionID()} keyed>
<Show when={sessionID()}>
{(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,9 +331,7 @@ export function SessionSidePanel(props: {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()} keyed>
{(p) => <FileVisual active path={p} />}
</Show>
<Show when={path()}>{(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)} keyed>
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
<Show when={byId().get(id)}>
{(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)} keyed>
<Show when={byId().get(id)}>
{(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} keyed>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId)} keyed>
<Show when={byId().get(draggedId())}>
{(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,24 +261,35 @@ 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:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
disabled: false,
onSelect: () => {
const sessionID = params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
if (sessionID) {
permission.toggleAutoAccept(sessionID, sdk.directory)
} else {
permission.toggleAutoAcceptDirectory(sdk.directory)
}
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
description: active
? 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.17",
"version": "1.2.20",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
"zh-tw",
])
const DOCS_LOCALE = {
ar: "ar",
da: "da",
de: "de",
en: "en",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nb: "no",
"pt-br": "br",
root: "en",
ru: "ru",
th: "th",
tr: "tr",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (value === "root") {
if (next.path === "/docs/en") return `/docs${next.suffix}`
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
return `${next.path}${next.suffix}`
}
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
return parseLocale(fix(pathname).split("/")[1])
}
export function fromDocsPathname(pathname: string) {
const next = fix(pathname)
const value = next.split("/")[2]?.toLowerCase()
if (!value) return null
if (!next.startsWith("/docs/")) return null
if (!(value in DOCS_LOCALE)) return null
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
}
export function strip(pathname: string) {
const locale = fromPathname(pathname)
if (!locale) return fix(pathname)
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
const fromPath = fromPathname(new URL(request.url).pathname)
if (fromPath) return fromPath
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
if (fromDocsPath) return fromDocsPath
return (
localeFromCookieHeader(request.headers.get("cookie")) ??
detectFromAcceptLanguage(request.headers.get("accept-language"))

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.17",
"version": "1.2.20",
"$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.17",
"version": "1.2.20",
"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.17",
"version": "1.2.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -76,6 +76,7 @@ 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.17",
"version": "1.2.20",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -77,6 +77,7 @@ 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.17",
"version": "1.2.20",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,10 +1,8 @@
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({
@@ -38,6 +36,81 @@ 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 = {
@@ -47,7 +120,7 @@ export namespace Share {
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Storage.write(["share", info.id], info)
await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
return info
})
@@ -60,8 +133,13 @@ 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 list = await Storage.list({ prefix: ["share_data", body.id] })
for (const item of list) {
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()) {
await Storage.remove(item)
}
})
@@ -75,59 +153,13 @@ 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)
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
await writeSnapshot(input.share.id, merge(data, input.data))
},
)
type Compaction = {
event?: string
data: Data[]
}
export async function data(shareID: string) {
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
return (await readSnapshot(shareID)) ?? legacy(shareID)
}
export const syncOld = fn(

View File

@@ -108,6 +108,7 @@ 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,12 +5,11 @@ 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 { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, 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"
@@ -20,11 +19,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) => ({
@@ -54,12 +53,6 @@ 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
}
@@ -79,12 +72,6 @@ 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",
@@ -101,28 +88,6 @@ 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] ?? []
@@ -143,17 +108,15 @@ 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")
const now = Date.now()
const data = getData(params.shareID)
console.log("getData", Date.now() - now)
return data
})
createEffect(() => {
console.log(data())
return getData(params.shareID)
})
return (
@@ -241,22 +204,8 @@ 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(() => {
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 diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
const title = () => (
<div class="flex flex-col gap-4">
@@ -380,18 +329,9 @@ 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()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
diffStyle={diffStyle()}
onDiffStyleChange={setDiffStyle}
classes={{
root: "pb-20",
header: "px-6",
@@ -419,11 +359,7 @@ export default function () {
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<Tabs.Content 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 events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(1)
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
expect(snapshot?.data).toHaveLength(1)
await Share.remove({ id: share.id, secret: share.secret })
})
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
data: data2,
})
const events = await Storage.list({ prefix: ["share_event", share.id] })
expect(events.length).toBe(2)
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
expect(snapshot?.data).toHaveLength(2)
await Share.remove({ id: share.id, secret: share.secret })
})
@@ -194,6 +194,28 @@ 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.17"
version = "1.2.20"
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.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/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.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/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.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/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.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.17",
"version": "1.2.20",
"$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.17",
"version": "1.2.20",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -43,9 +43,10 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -105,7 +106,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -127,12 +128,13 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5"
},
"overrides": {
"drizzle-orm": "1.0.0-beta.12-a5629fb"
"drizzle-orm": "1.0.0-beta.16-ea816b6"
}
}

View File

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

View File

@@ -31,6 +31,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -281,7 +282,7 @@ export namespace ACP {
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = String(Bun.hash(output))
const hash = Hash.fast(output)
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection

View File

@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await Bun.sleep(10)
await sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

View File

@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await Bun.sleep(1000)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},

View File

@@ -28,6 +28,7 @@ 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"
type GitHubAuthor = {
login: string
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await Bun.sleep(1000)
await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e

View File

@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
const lessOnPath = which("less")
if (lessOnPath) {
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
const git = which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]

View File

@@ -129,40 +129,14 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const localPermissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const localQuestions = createMemo(() => sync.data.question[route.sessionID] ?? [])
const childSessions = createMemo(() => {
if (session()?.parentID) return []
return children().filter((x) => x.id !== route.sessionID)
})
const permissions = createMemo(() => {
if (session()?.parentID) return []
const child = childSessions().flatMap((x) => sync.data.permission[x.id] ?? [])
return [...localPermissions(), ...child]
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
const questions = createMemo(() => {
if (session()?.parentID) return []
const child = childSessions().flatMap((x) => sync.data.question[x.id] ?? [])
return [...localQuestions(), ...child]
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const activeSubagents = createMemo(() =>
childSessions().flatMap((item) => {
const status = sync.data.session_status?.[item.id]
if (status?.type !== "busy" && status?.type !== "retry") return []
const count = (sync.data.message[item.id] ?? [])
.flatMap((message) => sync.data.part[message.id] ?? [])
.filter(
(part) => part.type === "tool" && (part.state.status === "completed" || part.state.status === "error"),
).length
return [
{
session: item,
status,
count,
},
]
}),
)
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1176,29 +1150,6 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={activeSubagents().length > 0}>
<box paddingLeft={3} paddingBottom={1} gap={0}>
<text fg={theme.text}>
<span style={{ fg: theme.textMuted }}>Subagents</span> {activeSubagents().length} running
<span style={{ fg: theme.textMuted }}> · {keybind.print("session_child_cycle")} open</span>
</text>
<For each={activeSubagents()}>
{(item) => (
<text
fg={theme.textMuted}
onMouseUp={() => {
navigate({
type: "session",
sessionID: item.session.id,
})
}}
>
{Locale.truncate(item.session.title, 36)} · {item.count} toolcalls
</text>
)}
</For>
</box>
</Show>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
@@ -1206,7 +1157,7 @@ export function Session() {
<QuestionPrompt request={questions()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && localPermissions().length === 0 && localQuestions().length === 0}
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1215,7 +1166,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={localPermissions().length > 0 || localQuestions().length > 0}
disabled={permissions().length > 0 || questions().length > 0}
onSubmit={() => {
toBottom()
}}
@@ -2002,8 +1953,10 @@ function WebSearch(props: ToolProps<any>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const keybind = useKeybind()
const { navigate } = useRoute()
const local = useLocal()
const sync = useSync()
onMount(() => {
@@ -2021,47 +1974,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
)
})
const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
const background = createMemo(() => props.metadata.background === true)
const status = createMemo(() => {
const sessionID = props.metadata.sessionId
if (!sessionID) return
return sync.data.session_status?.[sessionID]
})
const counts = createMemo(() => {
const all = tools()
const done = all.filter((item) => item.state.status === "completed" || item.state.status === "error").length
return {
all: all.length,
done,
}
})
const childRunning = createMemo(() => status()?.type === "busy" || status()?.type === "retry")
const latest = createMemo(() => {
const user = messages().findLast((msg) => msg.role === "user")
const assistant = messages().findLast((msg) => msg.role === "assistant")
return {
user,
assistant,
}
})
const terminal = createMemo(() => {
const assistant = latest().assistant
if (!assistant) return false
const user = latest().user
if (user && user.id > assistant.id) return false
if (assistant.error) return true
return !!assistant.finish && !["tool-calls", "unknown"].includes(assistant.finish)
})
const backgroundRunning = createMemo(() => background() && childRunning())
const failed = createMemo(() => !!background() && terminal() && !!latest().assistant?.error)
const statusLabel = createMemo(() => {
if (backgroundRunning()) return "running in background"
if (!terminal()) return "background task pending sync"
if (failed()) return "background task failed"
return "background task finished"
})
const isRunning = createMemo(() => props.part.state.status === "running" || childRunning())
const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
const isRunning = createMemo(() => props.part.state.status === "running")
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
@@ -2072,21 +1987,16 @@ function Task(props: ToolProps<typeof TaskTool>) {
const content = createMemo(() => {
if (!props.input.description) return ""
const toolLabel = `${childRunning() ? counts().done : counts().all} toolcalls`
const content = [`Task ${props.input.description}`]
if (background()) content.push(`${statusLabel()}`)
let content = [`Task ${props.input.description}`]
if (isRunning() && tools().length > 0) {
const title = current() && (current()!.state as any).title
if (title) content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
else content.push(`${toolLabel}`)
// content[0] += ` · ${tools().length} toolcalls`
if (current()) content.push(`${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`)
else content.push(`${tools().length} toolcalls`)
}
if (props.part.state.status === "completed") {
content.push(`${toolLabel} · ${Locale.duration(duration())}`)
} else if (props.metadata.sessionId) {
content.push(`${keybind.print("session_child_cycle")} view subagents`)
content.push(`${tools().length} toolcalls · ${Locale.duration(duration())}`)
}
return content.join("\n")

View File

@@ -6,6 +6,7 @@ import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -76,7 +77,7 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
@@ -85,7 +86,7 @@ export namespace Clipboard {
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
@@ -95,7 +96,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
@@ -109,7 +110,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {

View File

@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
await sleep(250)
continue
}
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
await Bun.sleep(250)
await sleep(250)
}
}
})().catch((error) => {

View File

@@ -25,12 +25,12 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
Bun.stderr.write(EOL)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
Bun.stderr.write(message.join(" "))
process.stderr.write(message.join(" "))
}
let blank = false
@@ -44,7 +44,7 @@ export namespace UI {
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: Bun.color("gray", "ansi") ?? "",
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}

View File

@@ -1240,7 +1240,7 @@ export namespace Config {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(options.path, updated).catch(() => {})
await Filesystem.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin && isFile) {
@@ -1401,3 +1401,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
: await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true

View File

@@ -418,7 +418,7 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
@@ -439,11 +439,12 @@ export namespace File {
}
}
const untrackedOutput = await $`git -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)
.quiet()
.nothrow()
.text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -464,11 +465,12 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git -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)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -539,8 +541,14 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) {
diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
}
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const patch = structuredPatch(file, file, original, content, "old", "new", {

View File

@@ -8,6 +8,7 @@ import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
@@ -126,7 +127,7 @@ export namespace Ripgrep {
)
const state = lazy(async () => {
const system = Bun.which("rg")
const system = which("rg")
if (system) {
const stat = await fs.stat(system).catch(() => undefined)
if (stat?.isFile()) return { filepath: system }

View File

@@ -60,6 +60,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
function number(key: string) {
const value = process.env[key]

View File

@@ -3,6 +3,7 @@ import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -18,7 +19,7 @@ export const gofmt: Info = {
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
return which("gofmt") !== null
},
}
@@ -27,7 +28,7 @@ export const mix: Info = {
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
return which("mix") !== null
},
}
@@ -152,7 +153,7 @@ export const zig: Info = {
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
return which("zig") !== null
},
}
@@ -171,7 +172,7 @@ export const ktlint: Info = {
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
return which("ktlint") !== null
},
}
@@ -180,7 +181,7 @@ export const ruff: Info = {
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!Bun.which("ruff")) return false
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
@@ -210,7 +211,7 @@ export const rlang: Info = {
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = Bun.which("air")
const airPath = which("air")
if (airPath == null) return false
try {
@@ -239,7 +240,7 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) {
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
@@ -253,7 +254,7 @@ export const rubocop: Info = {
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
return which("rubocop") !== null
},
}
@@ -262,7 +263,7 @@ export const standardrb: Info = {
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
return which("standardrb") !== null
},
}
@@ -271,7 +272,7 @@ export const htmlbeautifier: Info = {
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
return which("htmlbeautifier") !== null
},
}
@@ -280,7 +281,7 @@ export const dart: Info = {
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return Bun.which("dart") !== null
return which("dart") !== null
},
}
@@ -289,7 +290,7 @@ export const ocamlformat: Info = {
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!Bun.which("ocamlformat")) return false
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
},
@@ -300,7 +301,7 @@ export const terraform: Info = {
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return Bun.which("terraform") !== null
return which("terraform") !== null
},
}
@@ -309,7 +310,7 @@ export const latexindent: Info = {
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return Bun.which("latexindent") !== null
return which("latexindent") !== null
},
}
@@ -318,7 +319,7 @@ export const gleam: Info = {
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return Bun.which("gleam") !== null
return which("gleam") !== null
},
}
@@ -327,7 +328,7 @@ export const shfmt: Info = {
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return Bun.which("shfmt") !== null
return which("shfmt") !== null
},
}
@@ -336,7 +337,7 @@ export const nixfmt: Info = {
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return Bun.which("nixfmt") !== null
return which("nixfmt") !== null
},
}
@@ -345,7 +346,7 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return Bun.which("rustfmt") !== null
return which("rustfmt") !== null
},
}
@@ -372,7 +373,7 @@ export const ormolu: Info = {
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return Bun.which("ormolu") !== null
return which("ormolu") !== null
},
}
@@ -381,7 +382,7 @@ export const cljfmt: Info = {
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return Bun.which("cljfmt") !== null
return which("cljfmt") !== null
},
}
@@ -390,6 +391,6 @@ export const dfmt: Info = {
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return Bun.which("dfmt") !== null
return which("dfmt") !== null
},
}

View File

@@ -12,6 +12,7 @@ import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -75,7 +76,7 @@ export namespace LSPServer {
},
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
async spawn(root) {
const deno = Bun.which("deno")
const deno = which("deno")
if (!deno) {
log.info("deno not found, please install deno first")
return
@@ -122,7 +123,7 @@ export namespace LSPServer {
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("vue-language-server")
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -260,7 +261,7 @@ export namespace LSPServer {
let lintBin = await resolveBin(lintTarget)
if (!lintBin) {
const found = Bun.which("oxlint")
const found = which("oxlint")
if (found) lintBin = found
}
@@ -281,7 +282,7 @@ export namespace LSPServer {
let serverBin = await resolveBin(serverTarget)
if (!serverBin) {
const found = Bun.which("oxc_language_server")
const found = which("oxc_language_server")
if (found) serverBin = found
}
if (serverBin) {
@@ -332,7 +333,7 @@ export namespace LSPServer {
let bin: string | undefined
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = Bun.which("biome")
const found = which("biome")
if (found) bin = found
}
@@ -368,11 +369,11 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = Bun.which("gopls", {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
@@ -405,12 +406,12 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = Bun.which("rubocop", {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
const ruby = which("ruby")
const gem = which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
@@ -457,7 +458,7 @@ export namespace LSPServer {
return undefined
}
let binary = Bun.which("ty")
let binary = which("ty")
const initialization: Record<string, string> = {}
@@ -509,7 +510,7 @@ export namespace LSPServer {
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
let binary = Bun.which("pyright-langserver")
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
@@ -563,7 +564,7 @@ export namespace LSPServer {
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
let binary = Bun.which("elixir-ls")
let binary = which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
@@ -574,7 +575,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(binary))) {
const elixir = Bun.which("elixir")
const elixir = which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
@@ -625,12 +626,12 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = Bun.which("zls", {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
const zig = which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
@@ -737,11 +738,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = Bun.which("csharp-ls", {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
return
}
@@ -776,11 +777,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = Bun.which("fsautocomplete", {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
return
}
@@ -817,7 +818,7 @@ export namespace LSPServer {
async spawn(root) {
// Check if sourcekit-lsp is available in the PATH
// This is installed with the Swift toolchain
const sourcekit = Bun.which("sourcekit-lsp")
const sourcekit = which("sourcekit-lsp")
if (sourcekit) {
return {
process: spawn(sourcekit, {
@@ -828,7 +829,7 @@ export namespace LSPServer {
// If sourcekit-lsp not found, check if xcrun is available
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!Bun.which("xcrun")) return
if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
@@ -877,7 +878,7 @@ export namespace LSPServer {
},
extensions: [".rs"],
async spawn(root) {
const bin = Bun.which("rust-analyzer")
const bin = which("rust-analyzer")
if (!bin) {
log.info("rust-analyzer not found in path, please install it")
return
@@ -896,7 +897,7 @@ export namespace LSPServer {
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
const args = ["--background-index", "--clang-tidy"]
const fromPath = Bun.which("clangd")
const fromPath = which("clangd")
if (fromPath) {
return {
process: spawn(fromPath, args, {
@@ -1041,7 +1042,7 @@ export namespace LSPServer {
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("svelteserver")
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
@@ -1088,7 +1089,7 @@ export namespace LSPServer {
}
const tsdk = path.dirname(tsserver)
let binary = Bun.which("astro-ls")
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
@@ -1132,7 +1133,7 @@ export namespace LSPServer {
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
extensions: [".java"],
async spawn(root) {
const java = Bun.which("java")
const java = which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
@@ -1324,7 +1325,7 @@ export namespace LSPServer {
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -1380,7 +1381,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = Bun.which("lua-language-server", {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1512,7 +1513,7 @@ export namespace LSPServer {
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
@@ -1556,7 +1557,7 @@ export namespace LSPServer {
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
async spawn(root) {
const prisma = Bun.which("prisma")
const prisma = which("prisma")
if (!prisma) {
log.info("prisma not found, please install prisma")
return
@@ -1574,7 +1575,7 @@ export namespace LSPServer {
extensions: [".dart"],
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
async spawn(root) {
const dart = Bun.which("dart")
const dart = which("dart")
if (!dart) {
log.info("dart not found, please install dart first")
return
@@ -1592,7 +1593,7 @@ export namespace LSPServer {
extensions: [".ml", ".mli"],
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
async spawn(root) {
const bin = Bun.which("ocamllsp")
const bin = which("ocamllsp")
if (!bin) {
log.info("ocamllsp not found, please install ocaml-lsp-server")
return
@@ -1609,7 +1610,7 @@ export namespace LSPServer {
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("bash-language-server")
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
@@ -1648,7 +1649,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = Bun.which("terraform-ls", {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1731,7 +1732,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = Bun.which("texlab", {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1821,7 +1822,7 @@ export namespace LSPServer {
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("docker-langserver")
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
@@ -1860,7 +1861,7 @@ export namespace LSPServer {
extensions: [".gleam"],
root: NearestRoot(["gleam.toml"]),
async spawn(root) {
const gleam = Bun.which("gleam")
const gleam = which("gleam")
if (!gleam) {
log.info("gleam not found, please install gleam first")
return
@@ -1878,9 +1879,9 @@ export namespace LSPServer {
extensions: [".clj", ".cljs", ".cljc", ".edn"],
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
async spawn(root) {
let bin = Bun.which("clojure-lsp")
let bin = which("clojure-lsp")
if (!bin && process.platform === "win32") {
bin = Bun.which("clojure-lsp.exe")
bin = which("clojure-lsp.exe")
}
if (!bin) {
log.info("clojure-lsp not found, please install clojure-lsp first")
@@ -1909,7 +1910,7 @@ export namespace LSPServer {
return Instance.directory
},
async spawn(root) {
const nixd = Bun.which("nixd")
const nixd = which("nixd")
if (!nixd) {
log.info("nixd not found, please install nixd first")
return
@@ -1930,7 +1931,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = Bun.which("tinymist", {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -2024,7 +2025,7 @@ export namespace LSPServer {
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
async spawn(root) {
const bin = Bun.which("haskell-language-server-wrapper")
const bin = which("haskell-language-server-wrapper")
if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return
@@ -2042,7 +2043,7 @@ export namespace LSPServer {
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
const julia = which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return

View File

@@ -1,3 +1,4 @@
import { createConnection } from "net"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -160,21 +161,12 @@ export namespace McpOAuthCallback {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
resolve(false)
})
})

View File

@@ -4,6 +4,7 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -361,6 +362,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.1-codex",
@@ -602,7 +604,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { type: "failed" as const }
}
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}

View File

@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},

View File

@@ -18,24 +18,60 @@ const disposal = {
all: undefined as Promise<void> | undefined,
}
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
}
function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
let existing = cache.get(input.directory)
if (!existing) {
Log.Default.info("creating instance", { directory: input.directory })
existing = iife(async () => {
const { project, sandbox } = await Project.fromDirectory(input.directory)
const ctx = {
existing = track(
input.directory,
boot({
directory: input.directory,
worktree: sandbox,
project,
}
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
cache.set(input.directory, existing)
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
@@ -66,19 +102,19 @@ export const Instance = {
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
Log.Default.info("reloading instance", { directory: input.directory })
await State.dispose(input.directory)
cache.delete(input.directory)
const next = track(input.directory, boot(input))
emit(input.directory)
return await next
},
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
GlobalBus.emit("event", {
directory: Instance.directory,
payload: {
type: "server.instance.disposed",
properties: {
directory: Instance.directory,
},
},
})
emit(Instance.directory)
},
async disposeAll() {
if (disposal.all) return disposal.all

View File

@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -97,7 +98,7 @@ export namespace Project {
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
const gitBinary = which("git")
// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
@@ -346,6 +347,21 @@ export namespace Project {
return fromRow(row)
}
export async function initGit(input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!which("git")) throw new Error("Git is not installed")
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: z.string(),

View File

@@ -6,9 +6,10 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
@@ -795,7 +796,7 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const sdk = new Map<number, SDK>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1085,7 +1086,7 @@ export namespace Provider {
...model.headers,
}
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing

View File

@@ -6,6 +6,7 @@ import { Project } from "../../project/project"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
return c.json(Instance.project)
},
)
.post(
"/git/init",
describeRoute({
summary: "Initialize git repository",
description: "Create a git repository for the current project and return the refreshed project info.",
operationId: "project.initGit",
responses: {
200: {
description: "Project information after git initialization",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await Project.initGit({
directory: dir,
project: prev,
})
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({
directory: dir,
worktree: dir,
project: next,
init: InstanceBootstrap,
})
return c.json(next)
},
)
.patch(
"/:projectID",
describeRoute({

View File

@@ -1,6 +1,5 @@
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"
@@ -122,20 +121,35 @@ 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("id" in item ? (item.id as string) : ulid(), item)
existing.data.set(key(item), item)
}
return
}
const dataMap = new Map<string, Data>()
for (const item of data) {
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
dataMap.set(key(item), item)
}
const timeout = setTimeout(async () => {
@@ -182,10 +196,14 @@ export namespace ShareNext {
const diffs = await Session.diff(sessionID)
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
const models = await Promise.all(
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)),
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)),
)
await sync(sessionID, [
{

View File

@@ -1,8 +1,10 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -22,13 +24,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
@@ -39,7 +41,7 @@ export namespace Shell {
function fallback() {
if (process.platform === "win32") {
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = Bun.which("git")
const git = which("git")
if (git) {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
@@ -49,7 +51,7 @@ export namespace Shell {
return process.env.COMSPEC || "cmd.exe"
}
if (process.platform === "darwin") return "/bin/zsh"
const bash = Bun.which("bash")
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}

View File

@@ -1,6 +1,7 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
@@ -271,13 +272,12 @@ export namespace Snapshot {
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Bun.write(target, "")
await Filesystem.write(target, "")
return
}
const text = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(target, text)
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
}
async function excludes() {

View File

@@ -12,8 +12,10 @@ import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
export const NotFoundError = NamedError.create(
"NotFoundError",
@@ -25,13 +27,20 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export const Path = path.join(Global.Path.data, "opencode.db")
export const Path = (() => {
const name =
Installation.CHANNEL !== "latest" && !Flag.OPENCODE_DISABLE_CHANNEL_DB
? `opencode-${Installation.CHANNEL}.db`
: "opencode.db"
return path.join(Global.Path.data, name)
})()
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase<Schema>
type Journal = { sql: string; timestamp: number }[]
type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
@@ -62,6 +71,7 @@ export namespace Database {
return {
sql: readFileSync(file, "utf-8"),
timestamp: time(name),
name,
}
})
.filter(Boolean) as Journal
@@ -70,9 +80,9 @@ export namespace Database {
}
export const Client = lazy(() => {
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
log.info("opening database", { path: Path })
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
sqlite.run("PRAGMA journal_mode = WAL")
@@ -143,7 +153,7 @@ export namespace Database {
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = Client().transaction((tx) => {
const result = (Client().transaction as any)((tx: TxOrDb) => {
return ctx.provide({ tx, effects }, () => callback(tx))
})
for (const effect of effects) effect()

View File

@@ -7,7 +7,6 @@ import { GrepTool } from "./grep"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TaskStatusTool } from "./task_status"
import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
@@ -111,7 +110,6 @@ export namespace ToolRegistry {
EditTool,
WriteTool,
TaskTool,
TaskStatusTool,
WebFetchTool,
TodoWriteTool,
// TodoReadTool,

View File

@@ -6,7 +6,6 @@ import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { SessionStatus } from "../session/status"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
@@ -23,93 +22,8 @@ const parameters = z.object({
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
background: z
.boolean()
.optional()
.describe("When true, launch the subagent in the background and return immediately"),
})
function output(sessionID: string, text: string) {
return [
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
}
function backgroundOutput(sessionID: string) {
return [
`task_id: ${sessionID} (for polling this task with task_status)`,
"state: running",
"",
"<task_result>",
"Background task started. Continue your current work and call task_status when you need the result.",
"</task_result>",
].join("\n")
}
function backgroundMessage(input: {
sessionID: string
description: string
state: "completed" | "error"
text: string
}) {
const tag = input.state === "completed" ? "task_result" : "task_error"
const title =
input.state === "completed"
? `Background task completed: ${input.description}`
: `Background task failed: ${input.description}`
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, `<${tag}>`, input.text, `</${tag}>`].join("\n")
}
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
function resultTaskID(input: unknown) {
if (!input || typeof input !== "object") return
const taskID = Reflect.get(input, "task_id")
if (typeof taskID === "string") return taskID
}
function polled(input: { message: MessageV2.WithParts; taskID: string }) {
if (input.message.info.role !== "assistant") return false
return input.message.parts.some((part) => {
if (part.type !== "tool") return false
if (part.tool !== "task_status") return false
if (part.state.status !== "completed") return false
return resultTaskID(part.state.input) === input.taskID
})
}
async function latestUser(sessionID: string) {
const [message] = await Session.messages({
sessionID,
limit: 1,
})
if (!message) return
if (message.info.role !== "user") return
return message.info.id
}
async function continueParent(input: { parentID: string; userID: string; taskID: string }) {
const message =
SessionStatus.get(input.parentID).type === "idle"
? undefined
: await SessionPrompt.loop({
sessionID: input.parentID,
}).catch(() => undefined)
if (message && polled({ message, taskID: input.taskID })) return
if (SessionStatus.get(input.parentID).type !== "idle") return
if ((await latestUser(input.parentID)) !== input.userID) return
await SessionPrompt.loop({
sessionID: input.parentID,
})
}
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
@@ -189,110 +103,62 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const parentModel = {
const model = agent.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
const model = agent.model ?? parentModel
const background = params.background === true
const metadata = {
sessionId: session.id,
model,
...(background ? { background: true } : {}),
}
ctx.metadata({
title: params.description,
metadata,
metadata: {
sessionId: session.id,
model,
},
})
const run = async () => {
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const result = await SessionPrompt.prompt({
messageID: Identifier.ascending("message"),
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
return result.parts.findLast((x) => x.type === "text")?.text ?? ""
}
if (background) {
const inject = (state: "completed" | "error", text: string) =>
SessionPrompt.prompt({
sessionID: ctx.sessionID,
noReply: true,
model: {
modelID: parentModel.modelID,
providerID: parentModel.providerID,
},
agent: ctx.agent,
parts: [
{
type: "text",
synthetic: true,
text: backgroundMessage({
sessionID: session.id,
description: params.description,
state,
text,
}),
},
],
})
void run()
.then((text) =>
inject("completed", text)
.then((message) =>
continueParent({
parentID: ctx.sessionID,
userID: message.info.id,
taskID: session.id,
}),
)
.catch(() => {}),
)
.catch((error) =>
inject("error", errorText(error))
.then((message) =>
continueParent({
parentID: ctx.sessionID,
userID: message.info.id,
taskID: session.id,
}),
)
.catch(() => {}),
)
return {
title: params.description,
metadata,
output: backgroundOutput(session.id),
}
}
const messageID = Identifier.ascending("message")
function cancel() {
SessionPrompt.cancel(session.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const text = await run()
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return {
title: params.description,
metadata,
output: output(session.id, text),
metadata: {
sessionId: session.id,
model,
},
output,
}
},
}

View File

@@ -17,13 +17,11 @@ When NOT to use the Task tool:
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. By default, task waits for completion and returns the result immediately, along with a task_id you can reuse later to continue the same subagent session.
3. Set background=true to launch asynchronously. In background mode, continue your current work without waiting.
4. For background runs, use task_status(task_id=..., wait=false) to poll, or wait=true to block until done (optionally with timeout_ms).
5. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
6. The agent's outputs should generally be trusted
7. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
8. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session.
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):

View File

@@ -1,163 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./task_status.txt"
import { Identifier } from "../id/id"
import { Session } from "../session"
import { SessionStatus } from "../session/status"
import { MessageV2 } from "../session/message-v2"
type State = "running" | "completed" | "error"
const DEFAULT_TIMEOUT = 60_000
const POLL_MS = 300
const parameters = z.object({
task_id: Identifier.schema("session").describe("The task_id returned by the task tool"),
wait: z.boolean().optional().describe("When true, wait until the task reaches a terminal state or timeout"),
timeout_ms: z
.number()
.int()
.positive()
.optional()
.describe("Maximum milliseconds to wait when wait=true (default: 60000)"),
})
function format(input: { taskID: string; state: State; text: string }) {
return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", "<task_result>", input.text, "</task_result>"].join(
"\n",
)
}
function errorText(error: NonNullable<MessageV2.Assistant["error"]>) {
const data = error.data as Record<string, unknown> | undefined
const message = data?.message
if (typeof message === "string" && message) return message
return error.name
}
async function inspect(taskID: string) {
const status = SessionStatus.get(taskID)
if (status.type === "busy" || status.type === "retry") {
return {
state: "running" as const,
text: status.type === "retry" ? `Task is retrying: ${status.message}` : "Task is still running.",
}
}
let latestUser: MessageV2.User | undefined
let latestAssistant:
| {
info: MessageV2.Assistant
parts: MessageV2.Part[]
}
| undefined
for await (const item of MessageV2.stream(taskID)) {
if (!latestUser && item.info.role === "user") latestUser = item.info
if (!latestAssistant && item.info.role === "assistant") {
latestAssistant = {
info: item.info,
parts: item.parts,
}
}
if (latestUser && latestAssistant) break
}
if (!latestAssistant) {
return {
state: "running" as const,
text: "Task has started but has not produced output yet.",
}
}
if (latestUser && latestUser.id > latestAssistant.info.id) {
return {
state: "running" as const,
text: "Task is starting.",
}
}
const text = latestAssistant.parts.findLast((part) => part.type === "text")?.text ?? ""
if (latestAssistant.info.error) {
const summary = errorText(latestAssistant.info.error)
return {
state: "error" as const,
text: text || summary,
}
}
const done = latestAssistant.info.finish && !["tool-calls", "unknown"].includes(latestAssistant.info.finish)
if (done) {
return {
state: "completed" as const,
text,
}
}
return {
state: "running" as const,
text: text || "Task is still running.",
}
}
function sleep(ms: number, abort: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (abort.aborted) {
reject(new Error("Task status polling aborted"))
return
}
const onAbort = () => {
clearTimeout(timer)
reject(new Error("Task status polling aborted"))
}
const timer = setTimeout(() => {
abort.removeEventListener("abort", onAbort)
resolve()
}, ms)
abort.addEventListener("abort", onAbort, { once: true })
})
}
export const TaskStatusTool = Tool.define("task_status", {
description: DESCRIPTION,
parameters,
async execute(params, ctx) {
await Session.get(params.task_id)
let result = await inspect(params.task_id)
if (!params.wait || result.state !== "running") {
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: result.state,
timed_out: false,
},
output: format({ taskID: params.task_id, state: result.state, text: result.text }),
}
}
const timeout = params.timeout_ms ?? DEFAULT_TIMEOUT
const end = Date.now() + timeout
while (Date.now() < end) {
const left = end - Date.now()
await sleep(Math.min(POLL_MS, left), ctx.abort)
result = await inspect(params.task_id)
if (result.state !== "running") break
}
const done = result.state !== "running"
const text = done ? result.text : `Timed out after ${timeout}ms while waiting for task completion.`
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: result.state,
timed_out: !done,
},
output: format({ taskID: params.task_id, state: result.state, text }),
}
},
})

View File

@@ -1,13 +0,0 @@
Poll the status of a subagent task launched with the task tool.
Use this to check background tasks started with `task(background=true)`.
Parameters:
- `task_id` (required): the task session id returned by the task tool
- `wait` (optional): when true, wait for completion
- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true`
Returns compact, parseable output:
- `task_id`
- `state` (`running`, `completed`, or `error`)
- `<task_result>...</task_result>` containing final output, error summary, or current progress text

View File

@@ -0,0 +1,7 @@
import { createHash } from "crypto"
export namespace Hash {
export function fast(input: string | Buffer): string {
return createHash("sha1").update(input).digest("hex")
}
}

Some files were not shown because too many files have changed in this diff Show More